diff --git a/android/app/build.gradle b/android/app/build.gradle index a0d1a770170e..34a9fae0ba02 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -90,8 +90,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1001039402 - versionName "1.3.94-2" + versionCode 1001039500 + versionName "1.3.95-0" } flavorDimensions "default" diff --git a/config/webpack/webpack.common.js b/config/webpack/webpack.common.js index 8d423dbc4213..d12f602260e1 100644 --- a/config/webpack/webpack.common.js +++ b/config/webpack/webpack.common.js @@ -187,7 +187,6 @@ const webpackConfig = ({envFile = '.env', platform = 'web'}) => ({ 'react-native-config': 'react-web-config', 'react-native$': '@expensify/react-native-web', 'react-native-web': '@expensify/react-native-web', - 'lottie-react-native': 'react-native-web-lottie', // Module alias for web & desktop // https://webpack.js.org/configuration/resolve/#resolvealias diff --git a/contributingGuides/CONTRIBUTING.md b/contributingGuides/CONTRIBUTING.md index 5c51f16ffc4d..24e0d1878237 100644 --- a/contributingGuides/CONTRIBUTING.md +++ b/contributingGuides/CONTRIBUTING.md @@ -32,12 +32,9 @@ This project and everyone participating in it is governed by the Expensify [Code At this time, we are not hiring contractors in Crimea, North Korea, Russia, Iran, Cuba, or Syria. ## Slack channels -All contributors should be a member of **two** Slack channels: +All contributors should be a member of a shared Slack channel called [#expensify-open-source](https://expensify.slack.com/archives/C01GTK53T8Q) -- this channel is used to ask **general questions**, facilitate **discussions**, and make **feature requests**. -1. [#expensify-open-source](https://expensify.slack.com/archives/C01GTK53T8Q) -- used to ask **general questions**, facilitate **discussions**, and make **feature requests**. -2. [#expensify-bugs](https://expensify.slack.com/archives/C049HHMV9SM) -- used to discuss or report **bugs** specifically. - -Before requesting an invite to Slack please ensure your Upwork account is active, since we only pay via Upwork (see [below](https://github.com/Expensify/App/blob/main/contributingGuides/CONTRIBUTING.md#payment-for-contributions)). To request an invite to these two Slack channels, email contributors@expensify.com with the subject `Slack Channel Invites`. We'll send you an invite! +Before requesting an invite to Slack please ensure your Upwork account is active, since we only pay via Upwork (see [below](https://github.com/Expensify/App/blob/main/contributingGuides/CONTRIBUTING.md#payment-for-contributions)). To request an invite to Slack, email contributors@expensify.com with the subject `Slack Channel Invites`. We'll send you an invite! Note: Do not send direct messages to the Expensify team in Slack or Expensify Chat, they will not be able to respond. @@ -47,30 +44,21 @@ Note: if you are hired for an Upwork job and have any job-specific questions, pl If you've found a vulnerability, please email security@expensify.com with the subject `Vulnerability Report` instead of creating an issue. ## Payment for Contributions -We hire and pay external contributors via Upwork.com. If you'd like to be paid for contributing or reporting a bug, please create an Upwork account, apply for an available job in [GitHub](https://github.com/Expensify/App/issues?q=is%3Aopen+is%3Aissue+label%3A%22Help+Wanted%22), and finally apply for the job in Upwork once your proposal gets selected in GitHub. Please make sure your Upwork profile is **fully verified** before applying, otherwise you run the risk of not being paid. If you think your compensation should be increased for a specific job, you can request a reevaluation by commenting in the Github issue where the Upwork job was posted. +We hire and pay external contributors via Upwork.com. If you'd like to be paid for contributing, please create an Upwork account, apply for an available job in [GitHub](https://github.com/Expensify/App/issues?q=is%3Aopen+is%3Aissue+label%3A%22Help+Wanted%22), and finally apply for the job in Upwork once your proposal gets selected in GitHub. Please make sure your Upwork profile is **fully verified** before applying, otherwise you run the risk of not being paid. If you think your compensation should be increased for a specific job, you can request a reevaluation by commenting in the Github issue where the Upwork job was posted. -Payment for your contributions and bug reports will be made no less than 7 days after the pull request is deployed to production to allow for [regression](https://github.com/Expensify/App/blob/main/contributingGuides/CONTRIBUTING.md#regressions) testing. If you have not received payment after 8 days of the PR being deployed to production, and there are no [regressions](https://github.com/Expensify/App/blob/main/contributingGuides/CONTRIBUTING.md#regressions), please add a comment to the issue mentioning the BugZero team member (Look for the melvin-bot "Triggered auto assignment to... (`Bug`)" to see who this is). +Payment for your contributions will be made no less than 7 days after the pull request is deployed to production to allow for [regression](https://github.com/Expensify/App/blob/main/contributingGuides/CONTRIBUTING.md#regressions) testing. If you have not received payment after 8 days of the PR being deployed to production, and there are no [regressions](https://github.com/Expensify/App/blob/main/contributingGuides/CONTRIBUTING.md#regressions), please add a comment to the issue mentioning the BugZero team member (Look for the melvin-bot "Triggered auto assignment to... (`Bug`)" to see who this is). New contributors are limited to working on one job at a time, however experienced contributors may work on numerous jobs simultaneously. Please be aware that compensation for any support in solving an issue is provided **entirely at Expensify’s discretion**. Personal time or resources applied towards investigating a proposal **will not guarantee compensation**. Compensation is only guaranteed to those who **[propose a solution and get hired for that job](https://github.com/Expensify/App/blob/main/contributingGuides/CONTRIBUTING.md#propose-a-solution-for-the-job)**. We understand there may be cases where a selected proposal may take inspiration from a previous proposal. Unfortunately, it’s not possible for us to evaluate every individual case and we have no process that can efficiently do so. Issues with higher rewards come with higher risk factors so try to keep things civil and make the best proposal you can. Once again, **any information provided may not necessarily lead to you getting hired for that issue or compensated in any way.** -**Important:** Payment amounts are variable, dependent on when your PR is merged and if there are any [regressions](https://github.com/Expensify/App/blob/main/contributingGuides/CONTRIBUTING.md#regressions). Your PR will be reviewed by a [Contributor+ (C+)](https://github.com/Expensify/App/blob/main/contributingGuides/HOW_TO_BECOME_A_CONTRIBUTOR_PLUS.md). team member and an internal engineer. All tests must pass and all code must pass lint checks before a merge. - -**Payment timelines** are based on the day and timestamp the contributor is assigned to the Github issue by an Expensify employee: -- Merged PR within 3 business days (72 hours) - 50% **bonus** -- Merged PR within 6 business days (144 hours) - 0% bonus -- Merged PR within 9 business days (216 hours) - 50% **penalty** -- No PR within 12 business days - **Contract terminated** - -We specify exact hours to make sure we can clearly decide what is eligible for the bonus given our team is global and contributors span across all the timezones. +**Important:** Payment amounts are variable, dependent on if there are any [regressions](https://github.com/Expensify/App/blob/main/contributingGuides/CONTRIBUTING.md#regressions). Your PR will be reviewed by a [Contributor+ (C+)](https://github.com/Expensify/App/blob/main/contributingGuides/HOW_TO_BECOME_A_CONTRIBUTOR_PLUS.md) team member and an internal engineer. All tests must pass and all code must pass lint checks before a merge. ### Regressions If a PR causes a regression at any point within the regression period (starting when the code is merged and ending 168 hours (that's 7 days) after being deployed to production): - payments will be issued 7 days after all regressions are fixed (ie: deployed to production) - a 50% penalty will be applied to the Contributor and [Contributor+](https://github.com/Expensify/App/blob/main/contributingGuides/HOW_TO_BECOME_A_CONTRIBUTOR_PLUS.md) for each regression on an issue -- the assigned Contributor and [Contributor+](https://github.com/Expensify/App/blob/main/contributingGuides/HOW_TO_BECOME_A_CONTRIBUTOR_PLUS.md) are not eligible for the 50% urgency bonus The 168 hours (aka 7 days) will be measured by calculating the time between when the PR is merged, and when a bug is posted to the #expensify-bugs Slack channel. @@ -80,25 +68,6 @@ A job could be fixing a bug or working on a new feature. There are two ways you #### Finding a job that Expensify posted This is the most common scenario for contributors. The Expensify team posts new jobs to the Upwork job list [here](https://www.upwork.com/ab/jobs/search/?q=Expensify%20React%20Native&sort=recency&user_location_match=2) (you must be signed in to Upwork to view jobs). Each job in Upwork has a corresponding GitHub issue, which will include instructions to follow. You can also view all open jobs in the Expensify/App GH repository by searching for GH issues with the [`Help Wanted` label](https://github.com/Expensify/App/issues?q=is%3Aopen+is%3Aissue+label%3A%22Help+Wanted%22). Lastly, you can follow the [@ExpensifyOSS](https://twitter.com/ExpensifyOSS) Twitter account to see a live feed of jobs that are posted. -#### Raising jobs and bugs -It’s possible that you found a new bug that we haven’t posted as a job to the [GitHub repository](https://github.com/Expensify/App/issues?q=is%3Aissue). This is an opportunity to raise it and claim the bug bounty. If it's a valid bug that we choose to resolve by deploying it to production — either internally or via an external contributor — then we will compensate you $50 for identifying the bug (we do not compensate for reporting new feature requests). If the bug is fixed by a PR that is not associated with your bug report, then you will not be eligible for the corresponding compensation unless you can find the PR that fixed it and prove your bug report came first. -- Note: If you get assigned the job you proposed **and** you complete the job, this $50 for identifying the improvement is *in addition to* the reward you will be paid for completing the job. -- Note about proposed bugs: Expensify has the right not to pay the $50 reward if the suggested bug has already been reported. Following, if more than one contributor proposes the same bug, the contributor who posted it first in the [#expensify-bugs](https://expensify.slack.com/archives/C049HHMV9SM) Slack channel is the one who is eligible for the bonus. -- Note: whilst you may optionally propose a solution for that job on Slack, solutions are ultimately reviewed in GitHub. The onus is on you to propose the solution on GitHub, and/or ensure the issue creator will include a link to your proposal. - -Please follow these steps to propose a job or raise a bug: - -1. Check to ensure a GH issue does not already exist for this job in the [New Expensify Issue list](https://github.com/Expensify/App/issues). -2. Check to ensure the `Bug:` or `Feature Request:` was not already posted in Slack (specifically the #expensify-bugs or #expensify-open-source [Slack channels](https://github.com/Expensify/App/blob/main/contributingGuides/CONTRIBUTING.md#slack-channels)). Use your best judgement by searching for similar titles, words and issue descriptions. -3. If your bug or new feature matches with an existing issue, please comment on that Slack thread or GitHub issue with your findings if you think it will help solve the issue. -4. If there is no existing GitHub issue or Upwork job, check if the issue is happening on prod (as opposed to only happening on dev) -5. If the issue is just in dev then it means it's a new issue and has not been deployed to production. In this case, you should try to find the offending PR and comment in the issue tied to the PR and ask the assigned users to add the `DeployBlockerCash` label. If you can't find it, follow the reporting instructions in the next item, but note that the issue is a regression only found in dev and not in prod. -6. If the issue happens in main, staging, or production then report the issue(s) in the #expensify-bugs Slack channel, using the report bug workflow. You can do this by clicking 'Workflow > report Bug', or typing `/Report bug`. View [this guide](https://github.com/Expensify/App/blob/main/contributingGuides/HOW_TO_CREATE_A_PLAN.md) for help creating a plan when proposing a feature request. Please verify the bug's presence on **every** platform mentioned in the bug report template, and confirm this with a screen recording.. - - **Important note/reminder**: never share any information pertaining to a customer of Expensify when describing the bug. This includes, and is not limited to, a customer's name, email, and contact information. -7. The Applause team will review your job proposal in the appropriate slack channel. If you've provided a quality proposal that we choose to implement, a GitHub issue will be created and your Slack handle will be included in the original post after `Issue reported by:` -8. If an external contributor other than yourself is hired to work on the issue, you will also be hired for the same job in Upwork to receive your payout. No additional work is required. If the issue is fixed internally, a dedicated job will be created to hire and pay you after the issue is fixed. -9. Payment will be made 7 days after code is deployed to production if there are no regressions. If a regression is discovered, payment will be issued 7 days after all regressions are fixed. - >**Note:** Our problem solving approach at Expensify is to focus on high value problems and avoid small optimizations with results that are difficult to measure. We also prefer to identify and solve problems at their root. Given that, please ensure all proposed jobs fix a specific problem in a measurable way with evidence so they are easy to evaluate. Here's an example of a good problem/solution: > >**Problem:** The app start up time has regressed because we introduced "New Feature" in PR #12345 and is now 1042ms slower because `SomeComponent` is re-rendering 42 times. diff --git a/docs/_sass/_main.scss b/docs/_sass/_main.scss index f085379357c4..46434787d6df 100644 --- a/docs/_sass/_main.scss +++ b/docs/_sass/_main.scss @@ -35,31 +35,8 @@ html { } table { - margin-bottom: 20px; border-spacing: 0; border-collapse: collapse; - border-radius: 8px; - - // Box shadow is used here because border-radius and border-collapse don't work together. It leads to double borders. - // https://stackoverflow.com/questions/628301/the-border-radius-property-and-border-collapsecollapse-dont-mix-how-can-i-use - border-style: hidden; - box-shadow: 0 0 0 1px $color-green-borders; -} - -th:first-child { - border-top-left-radius: 8px; -} - -th:last-child { - border-top-right-radius: 8px; -} - -tr:last-child > td:first-child { - border-bottom-left-radius: 8px; -} - -tr:last-child > td:last-child { - border-bottom-right-radius: 8px; } caption, @@ -68,13 +45,6 @@ td { text-align: left; font-weight: 400; vertical-align: middle; - padding: 6px 13px; - border: 1px solid $color-green-borders; -} - -thead tr th { - font-weight: bold; - background-color: $color-green-highlightBG; } q, @@ -395,6 +365,43 @@ button { } } + table { + margin-bottom: 20px; + border-radius: 8px; + + // Box shadow is used here because border-radius and border-collapse don't work together. It leads to double borders. + // https://stackoverflow.com/questions/628301/the-border-radius-property-and-border-collapsecollapse-dont-mix-how-can-i-use + border-style: hidden; + box-shadow: 0 0 0 1px $color-green-borders; + } + + th:first-child { + border-top-left-radius: 8px; + } + + th:last-child { + border-top-right-radius: 8px; + } + + tr:last-child > td:first-child { + border-bottom-left-radius: 8px; + } + + tr:last-child > td:last-child { + border-bottom-right-radius: 8px; + } + + th, + td { + padding: 6px 13px; + border: 1px solid $color-green-borders; + } + + thead tr th { + font-weight: bold; + background-color: $color-green-highlightBG; + } + .img-wrap { display: flex; justify-content: space-around; diff --git a/docs/articles/expensify-classic/account-settings/Copilot.md b/docs/articles/expensify-classic/account-settings/Copilot.md new file mode 100644 index 000000000000..4fac402b7ced --- /dev/null +++ b/docs/articles/expensify-classic/account-settings/Copilot.md @@ -0,0 +1,69 @@ +--- +title: Copilot +description: Safely delegate tasks without sharing login information. +--- + +# About +The Copilot feature allows you to safely delegate tasks without sharing login information. Your chosen user can access your account through their own Expensify account, with customizable permissions to manage expenses, create reports, and more. This can even be extended to users outside your policy or domain. + +# How-to +# How to add a Copilot +1. Log into the Expensify desktop website. +2. Navigate to *Settings > Account > Account Details > _Copilot: Delegated Access_*. +3. Enter the email address or phone number of your Copilot and select whether you want to give them Full Access or the ability to Submit Only. + - *Full Access Copilot*: Your Copilot will have full access to your account. Nearly every action you can do and everything you can see in your account will also be available to your Copilot. They *will not* have the ability to add or remove other Copilots from your account. + - *Submit Only Copilot*: Your Copilot will have the same limitations as a Full Access Copilot, with the added restriction of not being able to approve reports on your behalf. +4. Click Invite Copilot. + +If your Copilot already has an Expensify account, they will get an email notifying them that they can now access your account from within their account as well. +If they do not already have an Expensify account, they will be provided with a link to create one. Once they have created their Expensify account, they will be able to access your account from within their own account. + +# How to use Copilot +A designated copilot can access another account via the Expensify website or the mobile app. + +## How to switch to Copilot mode (on the Expensify website): +1. Click your profile icon in the upper left side of the page. +2. In the “Copilot Access” section of the dropdown, choose the account you wish to access. +3. When you Copilot into someone else’s account, the Expensify header will change color and an airplane icon will appear. +4. You can return to your own account at any time by accessing the user menu and choosing “Return to your account”. + +## How to switch to Copilot Mode (on the mobile app): +1. Tap on the menu icon on the top left-hand side of the screen, then tap your profile icon. +2. Tap “Switch to Copilot Mode”, then choose the account you wish to access. +3. You can return to your own account at any time by accessing the user menu and choosing “Return to your account”. + +# How to remove a Copilot +If you ever need to remove a Copilot, you can do so by following the below steps: +1. Log into the Expensify desktop website +2. Navigate to *Settings > Your Account > Account Details > _Copilot: Delegated Access_* +3. Click the red X next to the Copilot you'd like to remove + + +# Deep Dive +## Copilot Permissions +A Copilot can do the following actions in your account: +- Prepare expenses on your behalf +- Approve and reimburse others' expenses on your behalf (Note: this applies only to **Full Access** Copilots) +- View and make changes to your account/domain/policy settings +- View all expenses you can see within your own account + +## Copilot restrictions +A Copilot cannot do the following actions in your account: +- Change or reset your password +- Add/remove other Copilots + +## Forwarding receipts to receipts@expensify.com as a Copilot +To ensure a receipt is routed to the Expensify account in which you are a copilot rather than your own you’ll need to do the following: +1. Forward the email to receipts@expensify.com +2. Put the email of the account in which you are a copilot in the subject line +3. Send + + +# FAQ +## Can a Copilot's Secondary Login be used to forward receipts? +Yes! A Copilot can use any of the email addresses tied to their account to forward receipts into the account of the person they're assisting. + +## I'm in Copilot mode for an account; Can I add another Copilot to that account on their behalf? +No, only the original account holder can add another Copilot to the account. +## Is there a restriction on the number of Copilots I can have or the number of users for whom I can act as a Copilot? +There is no limit! You can have as many Copilots as you like, and you can be a Copilot for as many users as you need. diff --git a/docs/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/Brex.md b/docs/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/Brex.md deleted file mode 100644 index a060e37146a5..000000000000 --- a/docs/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/Brex.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Brex -description: Brex ---- -## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/billing-and-subscriptions/Overview.md b/docs/articles/expensify-classic/billing-and-subscriptions/Billing-Overview.md similarity index 99% rename from docs/articles/expensify-classic/billing-and-subscriptions/Overview.md rename to docs/articles/expensify-classic/billing-and-subscriptions/Billing-Overview.md index b835db54cbf2..30a507a1f9df 100644 --- a/docs/articles/expensify-classic/billing-and-subscriptions/Overview.md +++ b/docs/articles/expensify-classic/billing-and-subscriptions/Billing-Overview.md @@ -1,5 +1,5 @@ --- -title: Billing in Expensify +title: Billing Overview description: An overview of how billing works in Expensify. --- # Overview diff --git a/docs/articles/expensify-classic/expensify-card/Card-Settings.md b/docs/articles/expensify-classic/expensify-card/Admin-Card-Settings-and-Features.md similarity index 98% rename from docs/articles/expensify-classic/expensify-card/Card-Settings.md rename to docs/articles/expensify-classic/expensify-card/Admin-Card-Settings-and-Features.md index a8d56f267757..3e2eb2deec46 100644 --- a/docs/articles/expensify-classic/expensify-card/Card-Settings.md +++ b/docs/articles/expensify-classic/expensify-card/Admin-Card-Settings-and-Features.md @@ -1,6 +1,6 @@ --- -title: Expensify Card Settings -description: Admin Card Settings and Features +title: Admin Card Settings and Features +description: An in-depth look into the Expensify Card program's admin controls and settings. --- # Overview diff --git a/docs/articles/expensify-classic/insights-and-custom-reporting/Fringe-Benefits.md b/docs/articles/expensify-classic/insights-and-custom-reporting/Fringe-Benefits.md new file mode 100644 index 000000000000..267c938a3edf --- /dev/null +++ b/docs/articles/expensify-classic/insights-and-custom-reporting/Fringe-Benefits.md @@ -0,0 +1,43 @@ +--- +title: Fringe Benefits +description: How to track your Fringe Benefits +--- +# Overview +If you’re looking to track and report expense data to calculate Fringe Benefits Tax (FBT), you can use Expensify’s special workflow that allows you to capture extra information and use a template to export to a spreadsheet. + +# How to set up Fringe Benefit Tax + +## Add Attendee Count Tags +First, you’ll need to add these two tags to your Workspace: +1) Number of Internal Attendees +2) Number of External Attendees + +These tags must be named exactly as written above, ensuring there are no extra spaces at the beginning or at the end. You’ll need to set the tags to be numbers 00 - 10 or whatever number you wish to go up to (up to the maximum number of attendees you would expect at any one time), one tag per number i.e. “01”, “02”, “03” etc. These tags can be added in addition to those that are pulled in from your accounting solution. Follow these [instructions](https://help.expensify.com/articles/expensify-classic/workspace-and-domain-settings/Tags#gsc.tab=0) to add tags. + +## Add Payroll Code +Go to **Settings > Workspaces > Group > _Workspace Name_ > Categories** and within the categories you wish to track FBT against, select **Edit Category** and add the code “TAG”: + +## Enable Workflow +Once you’ve added both tags (Internal Attendees and External Attendees) and added the payroll code “TAG” to FBT categories, you can send a request to Expensify at concierge@expensify.com to enable the FBT workflow. Please send the following request: +>“Can you please add the custom workflow/DEW named FRINGE_BENEFIT_TAX to my company workspace named ?” +Once the FBT workflow is enabled, it will require anything with the code “TAG” to include the two attendee count tags in order to be submitted. + + +# For Users +Once these steps are completed, users who create expenses coded with any category that has the payroll code “TAG” (e.g. Entertainment Expenses) but don’t add the internal and external attendee counts, will not be able to submit their expenses. +# For Admins +You are now able to create and run a report, which shows all expenses under these categories and also shows the number of internal and external attendees. Because we don’t presume to know all of the data points you wish to capture, you’ll need to create a Custom CSV export. +Here are a couple of examples of Excel formulas to use to report on attendees: +- `{expense:tag:ntag-1}` outputs the first tag the user chooses. +- `{expense:tag:ntag-3}` outputs the third tag the user chooses. + +Your expenses may have multiple levels of coding, i.e.: +- GL Code (Category) +- Department (Tag 1) +- Location (Tag 2) +- Number of Internal Attendees (Tag 3) +- Number of External Attendees (Tag 4) + +In the above case, you’ll want to use `{expense:tag:ntag-3}` and `{expense:tag:ntag-4}` as formulas to report on the number of internal and external attendees. + +Our article on [Custom Templates](https://help.expensify.com/articles/expensify-classic/insights-and-custom-reporting/Custom-Templates#gsc.tab=0) shows how to create a custom CSV. diff --git a/docs/articles/expensify-classic/integrations/travel-integrations/TripCatcher.md b/docs/articles/expensify-classic/integrations/travel-integrations/TripCatcher.md deleted file mode 100644 index 3ee1c8656b4b..000000000000 --- a/docs/articles/expensify-classic/integrations/travel-integrations/TripCatcher.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Coming Soon -description: Coming Soon ---- -## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/manage-employees-and-report-approvals/Approval-Workflows.md b/docs/articles/expensify-classic/manage-employees-and-report-approvals/Approval-Workflows.md index 1f69c1eee8f4..4c64ab1cefe4 100644 --- a/docs/articles/expensify-classic/manage-employees-and-report-approvals/Approval-Workflows.md +++ b/docs/articles/expensify-classic/manage-employees-and-report-approvals/Approval-Workflows.md @@ -52,28 +52,28 @@ This document explains how to manage employee expense reports and approval workf - *Final Approver (Finance/Accountant):* This is the person listed as the 'Approves to' in the Settings of the Second Approver. - This is what this setup looks like in the Workspace Members table. - Bryan submits his reports to Jim for 1st level approval. -![Insert alt text for accessibility here](https://help.expensify.com/assets/images/image-name.png){:width="100%"} +![Screenshot showing the People section of the workspace]({{site.url}}/assets/images/ManagingEmployeesAndReports_ApprovalWorkflows_1.png){:width="100%"} - All of the reports Jim approves are submitted to Kevin. Kevin is the 'approves to' in Jim's Settings. -![Insert alt text for accessibility here](https://help.expensify.com/assets/images/image-name.png){:width="100%"} +![Screenshot of Policy Member Editor]({{site.url}}/assets/images/ManagingEmployeesAndReports_ApprovalWorkflows_2.png){:width="100%"} - All of the reports Kevin approves are submitted to Lucy. Lucy is the 'approves to' in Kevin's Settings. -![Insert alt text for accessibility here](https://help.expensify.com/assets/images/image-name.png){:width="100%"} +![Screenshot of Policy Member Editor Approves to]({{site.url}}/assets/images/ManagingEmployeesAndReports_ApprovalWorkflows_3.png){:width="100%"} - Lucy is the final approver, so she doesn't submit her reports to anyone for review. -![Insert alt text for accessibility here](https://help.expensify.com/assets/images/image-name.png){:width="100%"} +![Screenshot of Policy Member Editor Final Approver]({{site.url}}/assets/images/ManagingEmployeesAndReports_ApprovalWorkflows_4.png){:width="100%"} - The final outcome: The member in the Submits To line is different than the person noted as the Approves To. ### Adding additional approver levels - You can also set a specific approver for Reports Totals in Settings. -![Insert alt text for accessibility here](https://help.expensify.com/assets/images/image-name.png){:width="100%"} +![Screenshot of Policy Member Editor Approves to]({{site.url}}/assets/images/ManagingEmployeesAndReports_ApprovalWorkflows_5.png){:width="100%"} - An example: The submitter's manager can approve any report up to a certain limit, let's say $500, and forward it to accounting. However, if a report is over that $500 limit, it has to be also approved by the department head before being forwarded to accounting. - To configure, click on Edit Settings next to the approving manager's email address and set the "If Report Total is Over" and "Then Approves to" fields. -![Insert alt text for accessibility here](https://help.expensify.com/assets/images/image-name.png){:width="100%"} -![Insert alt text for accessibility here](https://help.expensify.com/assets/images/image-name.png){:width="100%"} +![Screenshot of Workspace Member Settings]({{site.url}}/assets/images/ManagingEmployeesAndReports_ApprovalWorkflows_6.png){:width="100%"} +![Screenshot of Policy Member Editor Configure]({{site.url}}/assets/images/ManagingEmployeesAndReports_ApprovalWorkflows_7.png){:width="100%"} ### Setting category approvals @@ -89,7 +89,7 @@ This document explains how to manage employee expense reports and approval workf - To add a category approver in your Workspace: - Navigate to *Settings > Policies > Group > [Workspace Name] > Categories* - Click *"Edit Settings"* next to the category that requires the additional approver - - Select an approver and click *“Save”* + - Select an approver and click *"Save"* #### Tag approver @@ -106,4 +106,4 @@ Category and Tag approvers are inserted at the beginning of the approval workflo ### Workflow enforcement -- If you want to ensure your employees cannot override the workflow you set - enable workflow enforcement by following the steps below. As a Workspace Admin, you can choose to enforce your approval workflow by going. \ No newline at end of file +- If you want to ensure your employees cannot override the workflow you set - enable workflow enforcement. As a Workspace Admin, you can choose to enforce your approval workflow by going to Settings > Workspaces > Group > [Workspace Name] > People > Approval Mode. When enabled (which is the default setting for a new workspace), submitters and approvers must adhere to the set approval workflow (recommended). This setting does not apply to Workspace Admins, who are free to submit outside of this workflow diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index 3503f7218339..12125034d085 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -19,7 +19,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.3.94 + 1.3.95 CFBundleSignature ???? CFBundleURLTypes @@ -40,7 +40,7 @@ CFBundleVersion - 1.3.94.2 + 1.3.95.0 ITSAppUsesNonExemptEncryption LSApplicationQueriesSchemes diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index 29f6a6ea8c82..2978353ccb1a 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -15,10 +15,10 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 1.3.94 + 1.3.95 CFBundleSignature ???? CFBundleVersion - 1.3.94.2 + 1.3.95.0 diff --git a/package-lock.json b/package-lock.json index cf3354f37b18..bda52c288d5c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,16 @@ { "name": "new.expensify", - "version": "1.3.94-2", + "version": "1.3.95-0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "1.3.94-2", + "version": "1.3.95-0", "hasInstallScript": true, "license": "MIT", "dependencies": { + "@dotlottie/react-player": "^1.6.3", "@expensify/react-native-web": "0.18.15", "@formatjs/intl-datetimeformat": "^6.10.0", "@formatjs/intl-getcanonicallocales": "^2.2.0", @@ -21,6 +22,7 @@ "@invertase/react-native-apple-authentication": "^2.2.2", "@kie/act-js": "^2.0.1", "@kie/mock-github": "^1.0.0", + "@lottiefiles/react-lottie-player": "^3.5.3", "@oguzhnatly/react-native-image-manipulator": "github:Expensify/react-native-image-manipulator#5cdae3d4455b03a04c57f50be3863e2fe6c92c52", "@onfido/react-native-sdk": "8.3.0", "@react-native-async-storage/async-storage": "^1.17.10", @@ -57,7 +59,7 @@ "idb-keyval": "^6.2.1", "jest-when": "^3.5.2", "lodash": "4.17.21", - "lottie-react-native": "^6.3.1", + "lottie-react-native": "^6.4.0", "mapbox-gl": "^2.15.0", "moment": "^2.29.4", "moment-timezone": "^0.5.31", @@ -113,7 +115,6 @@ "react-native-view-shot": "^3.6.0", "react-native-vision-camera": "^2.16.2", "react-native-web-linear-gradient": "^1.1.2", - "react-native-web-lottie": "^1.4.4", "react-native-webview": "^11.17.2", "react-pdf": "^6.2.2", "react-plaid-link": "3.3.2", @@ -2942,6 +2943,14 @@ "node": ">=10.0.0" } }, + "node_modules/@dotlottie/react-player": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/@dotlottie/react-player/-/react-player-1.6.3.tgz", + "integrity": "sha512-wktLksV1LzV2qAAMocdBxn2e0J7XUraztLH2DnrlBYUgdy5Cz4FyV8+BPLftcyVD7r/4+0X448hEvK7tFQiLng==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/@dword-design/dedent": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/@dword-design/dedent/-/dedent-0.7.0.tgz", @@ -5635,6 +5644,17 @@ "dev": true, "license": "MIT" }, + "node_modules/@lottiefiles/react-lottie-player": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/@lottiefiles/react-lottie-player/-/react-lottie-player-3.5.3.tgz", + "integrity": "sha512-6pGbiTMjGnPddR1ur8M/TIDCiogZMc1aKIUbMEKXKAuNeYwZ2hvqwBJ+w5KRm88ccdcU88C2cGyLVsboFlSdVQ==", + "dependencies": { + "lottie-web": "^5.10.2" + }, + "peerDependencies": { + "react": "16 - 18" + } + }, "node_modules/@lwc/eslint-plugin-lwc": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@lwc/eslint-plugin-lwc/-/eslint-plugin-lwc-0.11.0.tgz", @@ -38114,15 +38134,23 @@ } }, "node_modules/lottie-react-native": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/lottie-react-native/-/lottie-react-native-6.3.1.tgz", - "integrity": "sha512-M18nAVYeGMF//bhL27D2zuMcrFPH0jbD/deBvcWi0CCcfZf6LQfx45xt+cuDqwr5nh6dMm+ta8KfZJmkbNhtlg==", + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/lottie-react-native/-/lottie-react-native-6.4.0.tgz", + "integrity": "sha512-wFO/gLPN1KliyznBa8OtYWkc9Vn9OEmIg1/b1536KANFtGaFAeoAGhijVxYKF3UPKJgjJYFmqg0W//FVrSXj+g==", "peerDependencies": { + "@dotlottie/react-player": "^1.6.1", + "@lottiefiles/react-lottie-player": "^3.5.3", "react": "*", "react-native": ">=0.46", "react-native-windows": ">=0.63.x" }, "peerDependenciesMeta": { + "@dotlottie/react-player": { + "optional": true + }, + "@lottiefiles/react-lottie-player": { + "optional": true + }, "react-native-windows": { "optional": true } @@ -45102,18 +45130,6 @@ "react-native-web": "*" } }, - "node_modules/react-native-web-lottie": { - "version": "1.4.4", - "resolved": "https://registry.npmjs.org/react-native-web-lottie/-/react-native-web-lottie-1.4.4.tgz", - "integrity": "sha512-W0jZiOf2u3Us6yASdpgAuL1+3Gw1EU/Wi5QAf6brzhXmJnq6/FMGCTf5zvSaX0yIurr9qcYB40DwAb4HwA6frg==", - "license": "MIT", - "dependencies": { - "lottie-web": "^5.7.1" - }, - "peerDependencies": { - "react-native-web": "*" - } - }, "node_modules/react-native-web/node_modules/memoize-one": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", @@ -55203,6 +55219,12 @@ "integrity": "sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==", "dev": true }, + "@dotlottie/react-player": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/@dotlottie/react-player/-/react-player-1.6.3.tgz", + "integrity": "sha512-wktLksV1LzV2qAAMocdBxn2e0J7XUraztLH2DnrlBYUgdy5Cz4FyV8+BPLftcyVD7r/4+0X448hEvK7tFQiLng==", + "requires": {} + }, "@dword-design/dedent": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/@dword-design/dedent/-/dedent-0.7.0.tgz", @@ -57115,6 +57137,14 @@ "integrity": "sha512-Hcv+nVC0kZnQ3tD9GVu5xSMR4VVYOteQIr/hwFPVEvPdlXqgGEuRjiheChHgdM+JyqdgNcmzZOX/tnl0JOiI7A==", "dev": true }, + "@lottiefiles/react-lottie-player": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/@lottiefiles/react-lottie-player/-/react-lottie-player-3.5.3.tgz", + "integrity": "sha512-6pGbiTMjGnPddR1ur8M/TIDCiogZMc1aKIUbMEKXKAuNeYwZ2hvqwBJ+w5KRm88ccdcU88C2cGyLVsboFlSdVQ==", + "requires": { + "lottie-web": "^5.10.2" + } + }, "@lwc/eslint-plugin-lwc": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@lwc/eslint-plugin-lwc/-/eslint-plugin-lwc-0.11.0.tgz", @@ -80510,9 +80540,9 @@ } }, "lottie-react-native": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/lottie-react-native/-/lottie-react-native-6.3.1.tgz", - "integrity": "sha512-M18nAVYeGMF//bhL27D2zuMcrFPH0jbD/deBvcWi0CCcfZf6LQfx45xt+cuDqwr5nh6dMm+ta8KfZJmkbNhtlg==", + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/lottie-react-native/-/lottie-react-native-6.4.0.tgz", + "integrity": "sha512-wFO/gLPN1KliyznBa8OtYWkc9Vn9OEmIg1/b1536KANFtGaFAeoAGhijVxYKF3UPKJgjJYFmqg0W//FVrSXj+g==", "requires": {} }, "lottie-web": { @@ -85599,14 +85629,6 @@ "integrity": "sha512-SmUnpwT49CEe78pXvIvYf72Es8Pv+ZYKCnEOgb2zAKpEUDMo0+xElfRJhwt5nfI8krJ5WbFPKnoDgD0uUjAN1A==", "requires": {} }, - "react-native-web-lottie": { - "version": "1.4.4", - "resolved": "https://registry.npmjs.org/react-native-web-lottie/-/react-native-web-lottie-1.4.4.tgz", - "integrity": "sha512-W0jZiOf2u3Us6yASdpgAuL1+3Gw1EU/Wi5QAf6brzhXmJnq6/FMGCTf5zvSaX0yIurr9qcYB40DwAb4HwA6frg==", - "requires": { - "lottie-web": "^5.7.1" - } - }, "react-native-webview": { "version": "11.23.0", "requires": { diff --git a/package.json b/package.json index 8a52d19d2a11..290f7538ab76 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "1.3.94-2", + "version": "1.3.95-0", "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.", @@ -58,6 +58,7 @@ "setup-https": "mkcert -install && mkcert -cert-file config/webpack/certificate.pem -key-file config/webpack/key.pem dev.new.expensify.com localhost 127.0.0.1" }, "dependencies": { + "@dotlottie/react-player": "^1.6.3", "@expensify/react-native-web": "0.18.15", "@formatjs/intl-datetimeformat": "^6.10.0", "@formatjs/intl-getcanonicallocales": "^2.2.0", @@ -69,6 +70,7 @@ "@invertase/react-native-apple-authentication": "^2.2.2", "@kie/act-js": "^2.0.1", "@kie/mock-github": "^1.0.0", + "@lottiefiles/react-lottie-player": "^3.5.3", "@oguzhnatly/react-native-image-manipulator": "github:Expensify/react-native-image-manipulator#5cdae3d4455b03a04c57f50be3863e2fe6c92c52", "@onfido/react-native-sdk": "8.3.0", "@react-native-async-storage/async-storage": "^1.17.10", @@ -105,7 +107,7 @@ "idb-keyval": "^6.2.1", "jest-when": "^3.5.2", "lodash": "4.17.21", - "lottie-react-native": "^6.3.1", + "lottie-react-native": "^6.4.0", "mapbox-gl": "^2.15.0", "moment": "^2.29.4", "moment-timezone": "^0.5.31", @@ -161,7 +163,6 @@ "react-native-view-shot": "^3.6.0", "react-native-vision-camera": "^2.16.2", "react-native-web-linear-gradient": "^1.1.2", - "react-native-web-lottie": "^1.4.4", "react-native-webview": "^11.17.2", "react-pdf": "^6.2.2", "react-plaid-link": "3.3.2", @@ -174,8 +175,6 @@ "underscore": "^1.13.1" }, "devDependencies": { - "@dword-design/eslint-plugin-import-alias": "^4.0.8", - "@trivago/prettier-plugin-sort-imports": "^4.2.0", "@actions/core": "1.10.0", "@actions/github": "5.1.1", "@babel/core": "^7.20.0", @@ -186,6 +185,7 @@ "@babel/preset-react": "^7.10.4", "@babel/preset-typescript": "^7.21.5", "@babel/runtime": "^7.20.0", + "@dword-design/eslint-plugin-import-alias": "^4.0.8", "@electron/notarize": "^2.1.0", "@jest/globals": "^29.5.0", "@octokit/core": "4.0.4", @@ -205,6 +205,7 @@ "@svgr/webpack": "^6.0.0", "@testing-library/jest-native": "5.4.1", "@testing-library/react-native": "11.5.1", + "@trivago/prettier-plugin-sort-imports": "^4.2.0", "@types/concurrently": "^7.0.0", "@types/jest": "^29.5.2", "@types/jest-when": "^3.5.2", diff --git a/patches/react-native-web-lottie+1.4.4.patch b/patches/react-native-web-lottie+1.4.4.patch deleted file mode 100644 index c82c33b5a7fe..000000000000 --- a/patches/react-native-web-lottie+1.4.4.patch +++ /dev/null @@ -1,9 +0,0 @@ -diff --git a/node_modules/react-native-web-lottie/dist/index.js b/node_modules/react-native-web-lottie/dist/index.js -index 7cd6b42..9c2b356 100644 ---- a/node_modules/react-native-web-lottie/dist/index.js -+++ b/node_modules/react-native-web-lottie/dist/index.js -@@ -1 +1 @@ --var _interopRequireDefault=require("@babel/runtime/helpers/interopRequireDefault");var _interopRequireWildcard=require("@babel/runtime/helpers/interopRequireWildcard");Object.defineProperty(exports,"__esModule",{value:true});exports.default=void 0;var _extends2=_interopRequireDefault(require("@babel/runtime/helpers/extends"));var _classCallCheck2=_interopRequireDefault(require("@babel/runtime/helpers/classCallCheck"));var _createClass2=_interopRequireDefault(require("@babel/runtime/helpers/createClass"));var _possibleConstructorReturn2=_interopRequireDefault(require("@babel/runtime/helpers/possibleConstructorReturn"));var _getPrototypeOf3=_interopRequireDefault(require("@babel/runtime/helpers/getPrototypeOf"));var _inherits2=_interopRequireDefault(require("@babel/runtime/helpers/inherits"));var _react=_interopRequireWildcard(require("react"));var _reactDom=_interopRequireDefault(require("react-dom"));var _View=_interopRequireDefault(require("react-native-web/dist/exports/View"));var _lottieWeb=_interopRequireDefault(require("lottie-web"));var _jsxFileName="/Users/louislagrange/Documents/Projets/react-native-web-community/react-native-web-lottie/src/index.js";var Animation=function(_PureComponent){(0,_inherits2.default)(Animation,_PureComponent);function Animation(){var _getPrototypeOf2;var _this;(0,_classCallCheck2.default)(this,Animation);for(var _len=arguments.length,args=new Array(_len),_key=0;_key<_len;_key++){args[_key]=arguments[_key];}_this=(0,_possibleConstructorReturn2.default)(this,(_getPrototypeOf2=(0,_getPrototypeOf3.default)(Animation)).call.apply(_getPrototypeOf2,[this].concat(args)));_this.animationDOMNode=null;_this.loadAnimation=function(props){if(_this.anim){_this.anim.destroy();}_this.anim=_lottieWeb.default.loadAnimation({container:_this.animationDOMNode,animationData:props.source,renderer:'svg',loop:props.loop||false,autoplay:props.autoPlay,rendererSettings:props.rendererSettings||{}});if(props.onAnimationFinish){_this.anim.addEventListener('complete',props.onAnimationFinish);}};_this.setAnimationDOMNode=function(ref){return _this.animationDOMNode=_reactDom.default.findDOMNode(ref);};_this.play=function(){if(!_this.anim){return;}for(var _len2=arguments.length,frames=new Array(_len2),_key2=0;_key2<_len2;_key2++){frames[_key2]=arguments[_key2];}_this.anim.playSegments(frames,true);};_this.reset=function(){if(!_this.anim){return;}_this.anim.stop();};return _this;}(0,_createClass2.default)(Animation,[{key:"componentDidMount",value:function componentDidMount(){var _this2=this;this.loadAnimation(this.props);if(typeof this.props.progress==='object'&&this.props.progress._listeners){this.props.progress.addListener(function(progress){var value=progress.value;var frame=value/(1/_this2.anim.getDuration(true));_this2.anim.goToAndStop(frame,true);});}}},{key:"componentWillUnmount",value:function componentWillUnmount(){if(typeof this.props.progress==='object'&&this.props.progress._listeners){this.props.progress.removeAllListeners();}}},{key:"UNSAFE_componentWillReceiveProps",value:function UNSAFE_componentWillReceiveProps(nextProps){if(this.props.source&&nextProps.source&&this.props.source.nm!==nextProps.source.nm){this.loadAnimation(nextProps);}}},{key:"render",value:function render(){return _react.default.createElement(_View.default,{style:this.props.style,ref:this.setAnimationDOMNode,__source:{fileName:_jsxFileName,lineNumber:71}});}}]);return Animation;}(_react.PureComponent);var _default=_react.default.forwardRef(function(props,ref){return _react.default.createElement(Animation,(0,_extends2.default)({},props,{ref:typeof ref=='function'?function(c){return ref(c&&c.anim);}:ref,__source:{fileName:_jsxFileName,lineNumber:76}}));});exports.default=_default; -\ No newline at end of file -+var _interopRequireWildcard=require("@babel/runtime/helpers/interopRequireWildcard");var _interopRequireDefault=require("@babel/runtime/helpers/interopRequireDefault");Object.defineProperty(exports,"__esModule",{value:true});exports.default=void 0;var _react=_interopRequireWildcard(require("react"));var _reactDom=_interopRequireDefault(require("react-dom"));var _View=_interopRequireDefault(require("react-native-web/dist/exports/View"));var _lottieWeb=_interopRequireDefault(require("lottie-web"));var _jsxFileName="/Users/roryabraham/react-native-web-lottie/src/index.js";function Animation(_ref){var source=_ref.source,_ref$renderer=_ref.renderer,renderer=_ref$renderer===void 0?'svg':_ref$renderer,_ref$loop=_ref.loop,loop=_ref$loop===void 0?false:_ref$loop,_ref$autoPlay=_ref.autoPlay,autoPlay=_ref$autoPlay===void 0?false:_ref$autoPlay,_ref$rendererSettings=_ref.rendererSettings,rendererSettings=_ref$rendererSettings===void 0?{}:_ref$rendererSettings,_ref$style=_ref.style,style=_ref$style===void 0?{}:_ref$style;var nm=source.nm;var anim=(0,_react.useRef)(null);var animationDOMNode=(0,_react.useRef)(null);(0,_react.useEffect)(function(){var _anim$current;(_anim$current=anim.current)==null?void 0:_anim$current.destroy();anim.current=_lottieWeb.default.loadAnimation({container:animationDOMNode.current,animationData:source,renderer:renderer,loop:loop,autoPlay:autoPlay,rendererSettings:rendererSettings});return function(){var _anim$current2;(_anim$current2=anim.current)==null?void 0:_anim$current2.destroy();};},[nm]);return _react.default.createElement(_View.default,{style:style,ref:function ref(r){return animationDOMNode.current=_reactDom.default.findDOMNode(r);},__source:{fileName:_jsxFileName,lineNumber:36}});}var _default=_react.default.memo(Animation);exports.default=_default; -\ No newline at end of file diff --git a/src/components/AddPlaidBankAccount.js b/src/components/AddPlaidBankAccount.js index 7010ab514617..566b6c709423 100644 --- a/src/components/AddPlaidBankAccount.js +++ b/src/components/AddPlaidBankAccount.js @@ -240,7 +240,7 @@ function AddPlaidBankAccount({ /> {bankName} - + { @@ -16,7 +27,7 @@ function AttachmentViewPdf({file, encryptedSourceUrl, isFocused, isUsedInCarouse const onScaleChanged = useCallback( (scale) => { - onScaleChangedProp(); + onScaleChangedProp(scale); // When a pdf is shown in a carousel, we want to disable the pager scroll when the pdf is zoomed in if (isUsedInCarousel) { @@ -49,7 +60,8 @@ function AttachmentViewPdf({file, encryptedSourceUrl, isFocused, isUsedInCarouse ); } -AttachmentViewPdf.propTypes = attachmentViewPdfPropTypes; -AttachmentViewPdf.defaultProps = attachmentViewPdfDefaultProps; +BaseAttachmentViewPdf.propTypes = attachmentViewPdfPropTypes; +BaseAttachmentViewPdf.defaultProps = attachmentViewPdfDefaultProps; +BaseAttachmentViewPdf.displayName = 'BaseAttachmentViewPdf'; -export default memo(AttachmentViewPdf); +export default memo(BaseAttachmentViewPdf); diff --git a/src/components/Attachments/AttachmentView/AttachmentViewPdf/index.android.js b/src/components/Attachments/AttachmentView/AttachmentViewPdf/index.android.js new file mode 100644 index 000000000000..46afd23daa4c --- /dev/null +++ b/src/components/Attachments/AttachmentView/AttachmentViewPdf/index.android.js @@ -0,0 +1,68 @@ +import React, {memo, useCallback, useContext} from 'react'; +import {StyleSheet, View} from 'react-native'; +import {Gesture, GestureDetector} from 'react-native-gesture-handler'; +import Animated, {useSharedValue} from 'react-native-reanimated'; +import AttachmentCarouselPagerContext from '@components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPagerContext'; +import styles from '@styles/styles'; +import BaseAttachmentViewPdf from './BaseAttachmentViewPdf'; +import {attachmentViewPdfDefaultProps, attachmentViewPdfPropTypes} from './propTypes'; + +function AttachmentViewPdf(props) { + const {onScaleChanged, ...restProps} = props; + const attachmentCarouselPagerContext = useContext(AttachmentCarouselPagerContext); + const scaleRef = useSharedValue(1); + const offsetX = useSharedValue(0); + const offsetY = useSharedValue(0); + + const Pan = Gesture.Pan() + .manualActivation(true) + .onTouchesMove((evt) => { + if (offsetX.value !== 0 && offsetY.value !== 0) { + // if the value of X is greater than Y and the pdf is not zoomed in, + // enable the pager scroll so that the user + // can swipe to the next attachment otherwise disable it. + if (Math.abs(evt.allTouches[0].absoluteX - offsetX.value) > Math.abs(evt.allTouches[0].absoluteY - offsetY.value) && scaleRef.value === 1) { + attachmentCarouselPagerContext.shouldPagerScroll.value = true; + } else { + attachmentCarouselPagerContext.shouldPagerScroll.value = false; + } + } + offsetX.value = evt.allTouches[0].absoluteX; + offsetY.value = evt.allTouches[0].absoluteY; + }); + + const updateScale = useCallback( + (scale) => { + scaleRef.value = scale; + }, + [scaleRef], + ); + + return ( + + + + { + updateScale(scale); + onScaleChanged(); + }} + /> + + + + ); +} + +AttachmentViewPdf.propTypes = attachmentViewPdfPropTypes; +AttachmentViewPdf.defaultProps = attachmentViewPdfDefaultProps; + +export default memo(AttachmentViewPdf); diff --git a/src/components/Attachments/AttachmentView/AttachmentViewPdf/index.ios.js b/src/components/Attachments/AttachmentView/AttachmentViewPdf/index.ios.js new file mode 100644 index 000000000000..103ff292760f --- /dev/null +++ b/src/components/Attachments/AttachmentView/AttachmentViewPdf/index.ios.js @@ -0,0 +1,17 @@ +import React, {memo} from 'react'; +import BaseAttachmentViewPdf from './BaseAttachmentViewPdf'; +import {attachmentViewPdfDefaultProps, attachmentViewPdfPropTypes} from './propTypes'; + +function AttachmentViewPdf(props) { + return ( + + ); +} + +AttachmentViewPdf.propTypes = attachmentViewPdfPropTypes; +AttachmentViewPdf.defaultProps = attachmentViewPdfDefaultProps; + +export default memo(AttachmentViewPdf); diff --git a/src/components/ConfirmationPage.js b/src/components/ConfirmationPage.js index bc154923e926..09dd8ae3da38 100644 --- a/src/components/ConfirmationPage.js +++ b/src/components/ConfirmationPage.js @@ -47,6 +47,7 @@ function ConfirmationPage(props) { autoPlay loop style={styles.confirmationAnimation} + webStyle={styles.confirmationAnimationWeb} /> {props.heading} {props.description} diff --git a/src/components/FullscreenLoadingIndicator.js b/src/components/FullscreenLoadingIndicator.js deleted file mode 100644 index 42be33ef3843..000000000000 --- a/src/components/FullscreenLoadingIndicator.js +++ /dev/null @@ -1,33 +0,0 @@ -import React from 'react'; -import {ActivityIndicator, StyleSheet, View} from 'react-native'; -import _ from 'underscore'; -import stylePropTypes from '@styles/stylePropTypes'; -import styles from '@styles/styles'; -import themeColors from '@styles/themes/default'; - -const propTypes = { - /** Additional style props */ - style: stylePropTypes, -}; - -const defaultProps = { - style: [], -}; - -function FullScreenLoadingIndicator(props) { - const additionalStyles = _.isArray(props.style) ? props.style : [props.style]; - return ( - - - - ); -} - -FullScreenLoadingIndicator.propTypes = propTypes; -FullScreenLoadingIndicator.defaultProps = defaultProps; -FullScreenLoadingIndicator.displayName = 'FullScreenLoadingIndicator'; - -export default FullScreenLoadingIndicator; diff --git a/src/components/FullscreenLoadingIndicator.tsx b/src/components/FullscreenLoadingIndicator.tsx new file mode 100644 index 000000000000..b4483d2e0113 --- /dev/null +++ b/src/components/FullscreenLoadingIndicator.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import {ActivityIndicator, StyleProp, StyleSheet, View, ViewStyle} from 'react-native'; +import styles from '@styles/styles'; +import themeColors from '@styles/themes/default'; + +type FullScreenLoadingIndicatorProps = { + style?: StyleProp; +}; + +function FullScreenLoadingIndicator({style}: FullScreenLoadingIndicatorProps) { + return ( + + + + ); +} + +FullScreenLoadingIndicator.displayName = 'FullScreenLoadingIndicator'; + +export default FullScreenLoadingIndicator; diff --git a/src/components/IllustratedHeaderPageLayout.js b/src/components/IllustratedHeaderPageLayout.js index 54a3b0e7b07c..bece92e8fdfc 100644 --- a/src/components/IllustratedHeaderPageLayout.js +++ b/src/components/IllustratedHeaderPageLayout.js @@ -41,6 +41,7 @@ function IllustratedHeaderPageLayout({backgroundColor, children, illustration, f diff --git a/src/components/Lottie/Lottie.tsx b/src/components/Lottie/Lottie.tsx index cf689224278f..6ee3bb544ed7 100644 --- a/src/components/Lottie/Lottie.tsx +++ b/src/components/Lottie/Lottie.tsx @@ -2,13 +2,18 @@ import LottieView, {LottieViewProps} from 'lottie-react-native'; import React, {forwardRef} from 'react'; import styles from '@styles/styles'; -const Lottie = forwardRef((props: LottieViewProps, ref) => ( - -)); +const Lottie = forwardRef((props: LottieViewProps, ref) => { + const aspectRatioStyle = styles.aspectRatioLottie(props.source); + + return ( + + ); +}); export default Lottie; diff --git a/src/components/QRCode/index.tsx b/src/components/QRCode.tsx similarity index 100% rename from src/components/QRCode/index.tsx rename to src/components/QRCode.tsx diff --git a/src/components/ReimbursementAccountLoadingIndicator.js b/src/components/ReimbursementAccountLoadingIndicator.js index 46aff91c93ea..590cfc5c7b11 100644 --- a/src/components/ReimbursementAccountLoadingIndicator.js +++ b/src/components/ReimbursementAccountLoadingIndicator.js @@ -39,6 +39,7 @@ function ReimbursementAccountLoadingIndicator(props) { autoPlay loop style={styles.loadingVBAAnimation} + webStyle={styles.loadingVBAAnimationWeb} /> {translate('reimbursementAccountLoadingAnimation.explanationLine')} diff --git a/src/components/ReportActionItem/MoneyRequestPreview.js b/src/components/ReportActionItem/MoneyRequestPreview.js index 97043fbd055d..3d696747de3d 100644 --- a/src/components/ReportActionItem/MoneyRequestPreview.js +++ b/src/components/ReportActionItem/MoneyRequestPreview.js @@ -274,7 +274,9 @@ function MoneyRequestPreview(props) { ) : ( - {getPreviewHeaderText() + (isSettled ? ` • ${getSettledMessage()}` : '')} + + {getPreviewHeaderText() + (isSettled ? ` • ${getSettledMessage()}` : '')} + {hasFieldErrors && ( {_.map(shownImages, ({thumbnail, image, transaction}, index) => { @@ -89,7 +93,16 @@ function ReportActionItemImages({images, size, total, isHovered}) { {isLastImage && remaining > 0 && ( - + + + {remaining > MAX_REMAINING ? `${MAX_REMAINING}+` : `+${remaining}`} )} diff --git a/src/components/ReportActionItem/TaskPreview.js b/src/components/ReportActionItem/TaskPreview.js index 63ece9fcb3e1..954799246857 100644 --- a/src/components/ReportActionItem/TaskPreview.js +++ b/src/components/ReportActionItem/TaskPreview.js @@ -8,10 +8,14 @@ import Checkbox from '@components/Checkbox'; import Icon from '@components/Icon'; import * as Expensicons from '@components/Icon/Expensicons'; import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback'; +import refPropTypes from '@components/refPropTypes'; import RenderHTML from '@components/RenderHTML'; +import {showContextMenuForReport} from '@components/ShowContextMenuContext'; import withCurrentUserPersonalDetails, {withCurrentUserPersonalDetailsDefaultProps, withCurrentUserPersonalDetailsPropTypes} from '@components/withCurrentUserPersonalDetails'; import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; import compose from '@libs/compose'; +import ControlSelection from '@libs/ControlSelection'; +import * as DeviceCapabilities from '@libs/DeviceCapabilities'; import getButtonState from '@libs/getButtonState'; import * as LocalePhoneNumber from '@libs/LocalePhoneNumber'; import Navigation from '@libs/Navigation/Navigation'; @@ -52,6 +56,16 @@ const propTypes = { ownerAccountID: PropTypes.number, }), + /** The chat report associated with taskReport */ + chatReportID: PropTypes.string.isRequired, + + /** Popover context menu anchor, used for showing context menu */ + contextMenuAnchor: refPropTypes, + + /** Callback for updating context menu active state, used for showing context menu */ + checkIfContextMenuActive: PropTypes.func, + + /* Onyx Props */ ...withLocalizePropTypes, ...withCurrentUserPersonalDetailsPropTypes, @@ -90,6 +104,9 @@ function TaskPreview(props) { Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(props.taskReportID))} + onPressIn={() => DeviceCapabilities.canUseTouchScreen() && ControlSelection.block()} + onPressOut={() => ControlSelection.unblock()} + onLongPress={(event) => showContextMenuForReport(event, props.contextMenuAnchor, props.chatReportID, props.action, props.checkIfContextMenuActive)} style={[styles.flexRow, styles.justifyContentBetween]} accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON} accessibilityLabel={props.translate('task.task')} diff --git a/src/components/SVGImage/index.js b/src/components/SVGImage/index.js deleted file mode 100644 index de915007cc29..000000000000 --- a/src/components/SVGImage/index.js +++ /dev/null @@ -1,19 +0,0 @@ -import React from 'react'; -import {Image} from 'react-native'; -import * as StyleUtils from '@styles/StyleUtils'; -import propTypes from './propTypes'; - -function SVGImage(props) { - return ( - - ); -} - -SVGImage.propTypes = propTypes; -SVGImage.displayName = 'SVGImage'; - -export default SVGImage; diff --git a/src/components/SVGImage/index.native.js b/src/components/SVGImage/index.native.js deleted file mode 100644 index 78b1f8ef7e78..000000000000 --- a/src/components/SVGImage/index.native.js +++ /dev/null @@ -1,18 +0,0 @@ -import React from 'react'; -import {SvgCssUri} from 'react-native-svg'; -import propTypes from './propTypes'; - -function SVGImage(props) { - return ( - - ); -} - -SVGImage.propTypes = propTypes; -SVGImage.displayName = 'SVGImage'; - -export default SVGImage; diff --git a/src/components/SVGImage/propTypes.js b/src/components/SVGImage/propTypes.js deleted file mode 100644 index 4e02ad42fde9..000000000000 --- a/src/components/SVGImage/propTypes.js +++ /dev/null @@ -1,17 +0,0 @@ -import PropTypes from 'prop-types'; - -const propTypes = { - /** The asset to render. */ - src: PropTypes.string.isRequired, - - /** The width of the image. */ - width: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, - - /** The height of the image. */ - height: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, - - /** The resize mode of the image. */ - resizeMode: PropTypes.oneOf(['cover', 'contain', 'stretch', 'repeat', 'center']), -}; - -export default propTypes; diff --git a/src/components/SafeAreaConsumer.js b/src/components/SafeAreaConsumer.tsx similarity index 55% rename from src/components/SafeAreaConsumer.js rename to src/components/SafeAreaConsumer.tsx index 25f22ed61ec4..7df73dbdb65f 100644 --- a/src/components/SafeAreaConsumer.js +++ b/src/components/SafeAreaConsumer.tsx @@ -1,29 +1,34 @@ -import PropTypes from 'prop-types'; import React from 'react'; -import {SafeAreaInsetsContext} from 'react-native-safe-area-context'; +import type {DimensionValue} from 'react-native'; +import {EdgeInsets, SafeAreaInsetsContext} from 'react-native-safe-area-context'; import * as StyleUtils from '@styles/StyleUtils'; -const propTypes = { - /** Children to render. */ - children: PropTypes.oneOfType([PropTypes.func, PropTypes.node]).isRequired, +type ChildrenProps = { + paddingTop?: DimensionValue; + paddingBottom?: DimensionValue; + insets?: EdgeInsets; + safeAreaPaddingBottomStyle: { + paddingBottom?: DimensionValue; + }; +}; + +type SafeAreaConsumerProps = { + children: React.FC; }; /** * This component is a light wrapper around the SafeAreaInsetsContext.Consumer. There are several places where we * may need not just the insets, but the computed styles so we save a few lines of code with this. - * - * @param {Object} props - * @returns {React.Component} */ -function SafeAreaConsumer(props) { +function SafeAreaConsumer({children}: SafeAreaConsumerProps) { return ( {(insets) => { - const {paddingTop, paddingBottom} = StyleUtils.getSafeAreaPadding(insets); - return props.children({ + const {paddingTop, paddingBottom} = StyleUtils.getSafeAreaPadding(insets ?? undefined); + return children({ paddingTop, paddingBottom, - insets, + insets: insets ?? undefined, safeAreaPaddingBottomStyle: {paddingBottom}, }); }} @@ -32,5 +37,5 @@ function SafeAreaConsumer(props) { } SafeAreaConsumer.displayName = 'SafeAreaConsumer'; -SafeAreaConsumer.propTypes = propTypes; + export default SafeAreaConsumer; diff --git a/src/languages/en.ts b/src/languages/en.ts index cf4f7e66101f..a0b1fe9993d3 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -1843,7 +1843,7 @@ export default { levelThreeResult: 'Message removed from channel plus anonymous warning and message is reported for review.', }, teachersUnitePage: { - teachersUnite: 'Teachers unite!', + teachersUnite: 'Teachers Unite', joinExpensifyOrg: 'Join Expensify.org in eliminating injustice around the world and help teachers split their expenses for classrooms in need!', iKnowATeacher: 'I know a teacher', iAmATeacher: 'I am a teacher', diff --git a/src/languages/es.ts b/src/languages/es.ts index f1e24a7a6777..f6bccad2291c 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -479,7 +479,7 @@ export default { buttonSearch: 'Buscar', buttonMySettings: 'Mi configuración', fabNewChat: 'Iniciar chat', - fabNewChatExplained: 'Iniciar chat', + fabNewChatExplained: 'Iniciar chat (Acción flotante)', chatPinned: 'Chat fijado', draftedMessage: 'Mensaje borrador', listOfChatMessages: 'Lista de mensajes del chat', @@ -2326,7 +2326,7 @@ export default { levelThreeResult: 'Mensaje eliminado del canal, más advertencia anónima y mensaje reportado para revisión.', }, teachersUnitePage: { - teachersUnite: '¡Profesores unidos!', + teachersUnite: 'Profesores Unidos', joinExpensifyOrg: 'Únete a Expensify.org para eliminar la injusticia en todo el mundo y ayuda a los profesores a dividir sus gastos para las aulas más necesitadas.', iKnowATeacher: 'Yo conozco a un profesor', iAmATeacher: 'Soy profesor', diff --git a/src/libs/DistanceRequestUtils.js b/src/libs/DistanceRequestUtils.js index 0cc4e39d83af..0f994cc54f93 100644 --- a/src/libs/DistanceRequestUtils.js +++ b/src/libs/DistanceRequestUtils.js @@ -90,7 +90,7 @@ const getDistanceMerchant = (hasRoute, distanceInMeters, unit, rate, currency, t const singularDistanceUnit = unit === CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES ? translate('common.mile') : translate('common.kilometer'); const unitString = distanceInUnits === 1 ? singularDistanceUnit : distanceUnit; const ratePerUnit = rate ? PolicyUtils.getUnitRateValue({rate}, toLocaleDigit) : translate('common.tbd'); - const currencySymbol = CurrencyUtils.getCurrencySymbol(currency) || `${currency} `; + const currencySymbol = rate ? CurrencyUtils.getCurrencySymbol(currency) || `${currency} ` : ''; return `${distanceInUnits} ${unitString} @ ${currencySymbol}${ratePerUnit} / ${singularDistanceUnit}`; }; diff --git a/src/libs/E2E/apiMocks/openApp.js b/src/libs/E2E/apiMocks/openApp.js index d50f4462cfd9..5e77d3912441 100644 --- a/src/libs/E2E/apiMocks/openApp.js +++ b/src/libs/E2E/apiMocks/openApp.js @@ -2131,7 +2131,7 @@ export default () => ({ report_2543745284790730: { reportID: '2543745284790730', ownerAccountID: 17, - managerEmail: 'fake6@gmail.com', + managerID: 16, currency: 'USD', chatReportID: '98817646', state: 'SUBMITTED', @@ -2143,7 +2143,7 @@ export default () => ({ report_4249286573496381: { reportID: '4249286573496381', ownerAccountID: 17, - managerEmail: 'christoph+hightraffic@margelo.io', + managerID: 21, currency: 'USD', chatReportID: '4867098979334014', state: 'SUBMITTED', diff --git a/src/libs/Navigation/AppNavigator/Navigators/CentralPaneNavigator.js b/src/libs/Navigation/AppNavigator/Navigators/CentralPaneNavigator.js deleted file mode 100644 index 20baf44b23f4..000000000000 --- a/src/libs/Navigation/AppNavigator/Navigators/CentralPaneNavigator.js +++ /dev/null @@ -1,36 +0,0 @@ -import {createStackNavigator} from '@react-navigation/stack'; -import React from 'react'; -import ReportScreenWrapper from '@libs/Navigation/AppNavigator/ReportScreenWrapper'; -import getCurrentUrl from '@libs/Navigation/currentUrl'; -import FreezeWrapper from '@libs/Navigation/FreezeWrapper'; -import styles from '@styles/styles'; -import SCREENS from '@src/SCREENS'; - -const Stack = createStackNavigator(); - -const url = getCurrentUrl(); -const openOnAdminRoom = url ? new URL(url).searchParams.get('openOnAdminRoom') : undefined; - -function CentralPaneNavigator() { - return ( - - - - - - ); -} - -export default CentralPaneNavigator; diff --git a/src/libs/Navigation/AppNavigator/Navigators/CentralPaneNavigator/BaseCentralPaneNavigator.js b/src/libs/Navigation/AppNavigator/Navigators/CentralPaneNavigator/BaseCentralPaneNavigator.js new file mode 100644 index 000000000000..a1646011e560 --- /dev/null +++ b/src/libs/Navigation/AppNavigator/Navigators/CentralPaneNavigator/BaseCentralPaneNavigator.js @@ -0,0 +1,33 @@ +import {createStackNavigator} from '@react-navigation/stack'; +import React from 'react'; +import ReportScreenWrapper from '@libs/Navigation/AppNavigator/ReportScreenWrapper'; +import getCurrentUrl from '@libs/Navigation/currentUrl'; +import styles from '@styles/styles'; +import SCREENS from '@src/SCREENS'; + +const Stack = createStackNavigator(); + +const url = getCurrentUrl(); +const openOnAdminRoom = url ? new URL(url).searchParams.get('openOnAdminRoom') : undefined; + +function BaseCentralPaneNavigator() { + return ( + + + + ); +} + +export default BaseCentralPaneNavigator; diff --git a/src/libs/Navigation/AppNavigator/Navigators/CentralPaneNavigator/index.js b/src/libs/Navigation/AppNavigator/Navigators/CentralPaneNavigator/index.js new file mode 100644 index 000000000000..711dd468c77d --- /dev/null +++ b/src/libs/Navigation/AppNavigator/Navigators/CentralPaneNavigator/index.js @@ -0,0 +1,10 @@ +import React from 'react'; +import BaseCentralPaneNavigator from './BaseCentralPaneNavigator'; + +// We don't need to use freeze wraper on web because we don't render all report routes anyway. +// You can see this optimalization in the customStackNavigator. +function CentralPaneNavigator() { + return ; +} + +export default CentralPaneNavigator; diff --git a/src/libs/Navigation/AppNavigator/Navigators/CentralPaneNavigator/index.native.js b/src/libs/Navigation/AppNavigator/Navigators/CentralPaneNavigator/index.native.js new file mode 100644 index 000000000000..45ab2f070717 --- /dev/null +++ b/src/libs/Navigation/AppNavigator/Navigators/CentralPaneNavigator/index.native.js @@ -0,0 +1,13 @@ +import React from 'react'; +import FreezeWrapper from '@libs/Navigation/FreezeWrapper'; +import BaseCentralPaneNavigator from './BaseCentralPaneNavigator'; + +function CentralPaneNavigator() { + return ( + + + + ); +} + +export default CentralPaneNavigator; diff --git a/src/libs/Navigation/AppNavigator/createCustomStackNavigator/index.js b/src/libs/Navigation/AppNavigator/createCustomStackNavigator/index.js index ae36f4aff9ad..194b86259107 100644 --- a/src/libs/Navigation/AppNavigator/createCustomStackNavigator/index.js +++ b/src/libs/Navigation/AppNavigator/createCustomStackNavigator/index.js @@ -1,8 +1,9 @@ import {createNavigatorFactory, useNavigationBuilder} from '@react-navigation/native'; import {StackView} from '@react-navigation/stack'; import PropTypes from 'prop-types'; -import React, {useRef} from 'react'; +import React, {useMemo, useRef} from 'react'; import useWindowDimensions from '@hooks/useWindowDimensions'; +import NAVIGATORS from '@src/NAVIGATORS'; import CustomRouter from './CustomRouter'; const propTypes = { @@ -25,6 +26,24 @@ const defaultProps = { screenOptions: undefined, }; +function splitRoutes(routes) { + const reportRoutes = []; + const rhpRoutes = []; + const otherRoutes = []; + + routes.forEach((route) => { + if (route.name === NAVIGATORS.CENTRAL_PANE_NAVIGATOR) { + reportRoutes.push(route); + } else if (route.name === NAVIGATORS.RIGHT_MODAL_NAVIGATOR) { + rhpRoutes.push(route); + } else { + otherRoutes.push(route); + } + }); + + return {reportRoutes, rhpRoutes, otherRoutes}; +} + function ResponsiveStackNavigator(props) { const {isSmallScreenWidth} = useWindowDimensions(); @@ -40,12 +59,25 @@ function ResponsiveStackNavigator(props) { getIsSmallScreenWidth: () => isSmallScreenWidthRef.current, }); + const stateToRender = useMemo(() => { + const {reportRoutes, rhpRoutes, otherRoutes} = splitRoutes(state.routes); + + // Remove all report routes except the last 3. This will improve performance. + const limitedReportRoutes = reportRoutes.slice(-3); + + return { + ...state, + index: otherRoutes.length + limitedReportRoutes.length + rhpRoutes.length - 1, + routes: [...otherRoutes, ...limitedReportRoutes, ...rhpRoutes], + }; + }, [state]); + return ( diff --git a/src/libs/Navigation/AppNavigator/createCustomStackNavigator/index.native.js b/src/libs/Navigation/AppNavigator/createCustomStackNavigator/index.native.js new file mode 100644 index 000000000000..ae36f4aff9ad --- /dev/null +++ b/src/libs/Navigation/AppNavigator/createCustomStackNavigator/index.native.js @@ -0,0 +1,60 @@ +import {createNavigatorFactory, useNavigationBuilder} from '@react-navigation/native'; +import {StackView} from '@react-navigation/stack'; +import PropTypes from 'prop-types'; +import React, {useRef} from 'react'; +import useWindowDimensions from '@hooks/useWindowDimensions'; +import CustomRouter from './CustomRouter'; + +const propTypes = { + /* Determines if the navigator should render the StackView (narrow) or ThreePaneView (wide) */ + isSmallScreenWidth: PropTypes.bool.isRequired, + + /* Children for the useNavigationBuilder hook */ + children: PropTypes.oneOfType([PropTypes.func, PropTypes.node]).isRequired, + + /* initialRouteName for this navigator */ + initialRouteName: PropTypes.oneOf([PropTypes.string, PropTypes.undefined]), + + /* Screen options defined for this navigator */ + // eslint-disable-next-line react/forbid-prop-types + screenOptions: PropTypes.object, +}; + +const defaultProps = { + initialRouteName: undefined, + screenOptions: undefined, +}; + +function ResponsiveStackNavigator(props) { + const {isSmallScreenWidth} = useWindowDimensions(); + + const isSmallScreenWidthRef = useRef(isSmallScreenWidth); + + isSmallScreenWidthRef.current = isSmallScreenWidth; + + const {navigation, state, descriptors, NavigationContent} = useNavigationBuilder(CustomRouter, { + children: props.children, + screenOptions: props.screenOptions, + initialRouteName: props.initialRouteName, + // Options for useNavigationBuilder won't update on prop change, so we need to pass a getter for the router to have the current state of isSmallScreenWidth. + getIsSmallScreenWidth: () => isSmallScreenWidthRef.current, + }); + + return ( + + + + ); +} + +ResponsiveStackNavigator.defaultProps = defaultProps; +ResponsiveStackNavigator.propTypes = propTypes; +ResponsiveStackNavigator.displayName = 'ResponsiveStackNavigator'; + +export default createNavigatorFactory(ResponsiveStackNavigator); diff --git a/src/libs/ReportUtils.js b/src/libs/ReportUtils.js index 10630d7fb122..deac1b498e3f 100644 --- a/src/libs/ReportUtils.js +++ b/src/libs/ReportUtils.js @@ -2352,13 +2352,12 @@ function getOptimisticDataForParentReportAction(reportID, lastVisibleActionCreat * Builds an optimistic reportAction for the parent report when a task is created * @param {String} taskReportID - Report ID of the task * @param {String} taskTitle - Title of the task - * @param {String} taskAssignee - Email of the person assigned to the task * @param {Number} taskAssigneeAccountID - AccountID of the person assigned to the task * @param {String} text - Text of the comment * @param {String} parentReportID - Report ID of the parent report * @returns {Object} */ -function buildOptimisticTaskCommentReportAction(taskReportID, taskTitle, taskAssignee, taskAssigneeAccountID, text, parentReportID) { +function buildOptimisticTaskCommentReportAction(taskReportID, taskTitle, taskAssigneeAccountID, text, parentReportID) { const reportAction = buildOptimisticAddCommentReportAction(text); reportAction.reportAction.message[0].taskReportID = taskReportID; @@ -3923,7 +3922,6 @@ function shouldDisableRename(report, policy) { /** * Returns the onyx data needed for the task assignee chat * @param {Number} accountID - * @param {String} assigneeEmail * @param {Number} assigneeAccountID * @param {String} taskReportID * @param {String} assigneeChatReportID @@ -3932,7 +3930,7 @@ function shouldDisableRename(report, policy) { * @param {Object} assigneeChatReport * @returns {Object} */ -function getTaskAssigneeChatOnyxData(accountID, assigneeEmail, assigneeAccountID, taskReportID, assigneeChatReportID, parentReportID, title, assigneeChatReport) { +function getTaskAssigneeChatOnyxData(accountID, assigneeAccountID, taskReportID, assigneeChatReportID, parentReportID, title, assigneeChatReport) { // Set if we need to add a comment to the assignee chat notifying them that they have been assigned a task let optimisticAssigneeAddComment; // Set if this is a new chat that needs to be created for the assignee @@ -4000,7 +3998,7 @@ function getTaskAssigneeChatOnyxData(accountID, assigneeEmail, assigneeAccountID // If you're choosing to share the task in the same DM as the assignee then we don't need to create another reportAction indicating that you've been assigned if (assigneeChatReportID !== parentReportID) { const displayname = lodashGet(allPersonalDetails, [assigneeAccountID, 'displayName']) || lodashGet(allPersonalDetails, [assigneeAccountID, 'login'], ''); - optimisticAssigneeAddComment = buildOptimisticTaskCommentReportAction(taskReportID, title, assigneeEmail, assigneeAccountID, `assigned to ${displayname}`, parentReportID); + optimisticAssigneeAddComment = buildOptimisticTaskCommentReportAction(taskReportID, title, assigneeAccountID, `assigned to ${displayname}`, parentReportID); const lastAssigneeCommentText = formatReportLastMessageText(optimisticAssigneeAddComment.reportAction.message[0].text); const optimisticAssigneeReport = { lastVisibleActionCreated: currentTime, @@ -4308,4 +4306,5 @@ export { shouldUseFullTitleToDisplay, parseReportRouteParams, getReimbursementQueuedActionMessage, + getPersonalDetailsForAccountID, }; diff --git a/src/libs/actions/IOU.js b/src/libs/actions/IOU.js index 9be87312775a..19ac03228753 100644 --- a/src/libs/actions/IOU.js +++ b/src/libs/actions/IOU.js @@ -1803,7 +1803,7 @@ function editMoneyRequest(transactionID, transactionThreadReportID, transactionC // Update the last message of the chat report const hasNonReimbursableTransactions = ReportUtils.hasNonReimbursableTransactions(iouReport); const messageText = Localize.translateLocal(hasNonReimbursableTransactions ? 'iou.payerSpentAmount' : 'iou.payerOwesAmount', { - payer: updatedMoneyRequestReport.managerEmail, + payer: ReportUtils.getPersonalDetailsForAccountID(updatedMoneyRequestReport.managerID).login || '', amount: CurrencyUtils.convertToDisplayString(updatedMoneyRequestReport.total, updatedMoneyRequestReport.currency), }); updatedChatReport.lastMessageText = messageText; @@ -2048,7 +2048,7 @@ function deleteMoneyRequest(transactionID, reportAction, isSingleTransactionView updatedReportPreviewAction = {...reportPreviewAction}; const hasNonReimbursableTransactions = ReportUtils.hasNonReimbursableTransactions(iouReport); const messageText = Localize.translateLocal(hasNonReimbursableTransactions ? 'iou.payerSpentAmount' : 'iou.payerOwesAmount', { - payer: updatedIOUReport.managerEmail, + payer: ReportUtils.getPersonalDetailsForAccountID(updatedIOUReport.managerID).login || '', amount: CurrencyUtils.convertToDisplayString(updatedIOUReport.total, updatedIOUReport.currency), }); updatedReportPreviewAction.message[0].text = messageText; @@ -2694,7 +2694,6 @@ function submitReport(expenseReport) { 'SubmitReport', { reportID: expenseReport.reportID, - managerEmail: expenseReport.managerEmail, managerAccountID: expenseReport.managerID, reportActionID: optimisticSubmittedReportAction.reportActionID, }, diff --git a/src/libs/actions/Task.js b/src/libs/actions/Task.js index 551b04a3a85b..511999b5b3e1 100644 --- a/src/libs/actions/Task.js +++ b/src/libs/actions/Task.js @@ -71,7 +71,7 @@ function createTaskAndNavigate(parentReportID, title, description, assigneeEmail // Parent ReportAction indicating that a task has been created const optimisticTaskCreatedAction = ReportUtils.buildOptimisticCreatedReportAction(currentUserEmail); - const optimisticAddCommentReport = ReportUtils.buildOptimisticTaskCommentReportAction(taskReportID, title, assigneeEmail, assigneeAccountID, `task for ${title}`, parentReportID); + const optimisticAddCommentReport = ReportUtils.buildOptimisticTaskCommentReportAction(taskReportID, title, assigneeAccountID, `task for ${title}`, parentReportID); optimisticTaskReport.parentReportActionID = optimisticAddCommentReport.reportAction.reportActionID; const currentTime = DateUtils.getDBTime(); @@ -148,7 +148,6 @@ function createTaskAndNavigate(parentReportID, title, description, assigneeEmail if (assigneeChatReport) { assigneeChatReportOnyxData = ReportUtils.getTaskAssigneeChatOnyxData( currentUserAccountID, - assigneeEmail, assigneeAccountID, taskReportID, assigneeChatReportID, @@ -439,7 +438,6 @@ function editTaskAssigneeAndNavigate(report, ownerAccountID, assigneeEmail, assi const optimisticReport = { reportName, managerID: assigneeAccountID || report.managerID, - managerEmail: assigneeEmail || report.managerEmail, pendingFields: { ...(assigneeAccountID && {managerID: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}), }, @@ -473,7 +471,7 @@ function editTaskAssigneeAndNavigate(report, ownerAccountID, assigneeEmail, assi { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`, - value: {assignee: report.managerEmail, assigneeAccountID: report.managerID}, + value: {managerID: report.managerID}, }, ]; @@ -487,7 +485,6 @@ function editTaskAssigneeAndNavigate(report, ownerAccountID, assigneeEmail, assi assigneeChatReportOnyxData = ReportUtils.getTaskAssigneeChatOnyxData( currentUserAccountID, - assigneeEmail, assigneeAccountID, report.reportID, assigneeChatReportID, @@ -504,8 +501,7 @@ function editTaskAssigneeAndNavigate(report, ownerAccountID, assigneeEmail, assi 'EditTaskAssignee', { taskReportID: report.reportID, - assignee: assigneeEmail || report.managerEmail, - assigneeAccountID: assigneeAccountID || report.managerID, + assignee: assigneeEmail, editedTaskReportActionID: editTaskReportAction.reportActionID, assigneeChatReportID, assigneeChatReportActionID: diff --git a/src/pages/PrivateNotes/PrivateNotesEditPage.js b/src/pages/PrivateNotes/PrivateNotesEditPage.js index b31e9b58cbe9..7c8aec8d12de 100644 --- a/src/pages/PrivateNotes/PrivateNotesEditPage.js +++ b/src/pages/PrivateNotes/PrivateNotesEditPage.js @@ -132,7 +132,7 @@ function PrivateNotesEditPage({route, personalDetailsList, session, report}) { Navigation.goBack(ROUTES.PRIVATE_NOTES_VIEW.getRoute(report.repotID, route.params.accountID))} + onBackButtonPress={() => Navigation.goBack(ROUTES.PRIVATE_NOTES_VIEW.getRoute(report.reportID, route.params.accountID))} shouldShowBackButton onCloseButtonPress={() => Navigation.dismissModal()} /> diff --git a/src/pages/home/report/ReportActionCompose/Suggestions.js b/src/pages/home/report/ReportActionCompose/Suggestions.js index f39a70a960cf..0050b56800cc 100644 --- a/src/pages/home/report/ReportActionCompose/Suggestions.js +++ b/src/pages/home/report/ReportActionCompose/Suggestions.js @@ -1,5 +1,6 @@ import PropTypes from 'prop-types'; import React, {useCallback, useImperativeHandle, useRef} from 'react'; +import {View} from 'react-native'; import SuggestionEmoji from './SuggestionEmoji'; import SuggestionMention from './SuggestionMention'; import * as SuggestionProps from './suggestionProps'; @@ -108,7 +109,7 @@ function Suggestions({ }; return ( - <> + - + ); } diff --git a/src/pages/home/report/ReportActionItem.js b/src/pages/home/report/ReportActionItem.js index a7a3bc0739f3..4da88fd5d352 100644 --- a/src/pages/home/report/ReportActionItem.js +++ b/src/pages/home/report/ReportActionItem.js @@ -353,11 +353,16 @@ function ReportActionItem(props) { ); } else if (ReportActionsUtils.isCreatedTaskReportAction(props.action)) { children = ( - + + + ); } else if (props.action.actionName === CONST.REPORT.ACTIONS.TYPE.REIMBURSEMENTQUEUED) { const submitterDisplayName = PersonalDetailsUtils.getDisplayNameOrDefault(personalDetails, [props.report.ownerAccountID, 'displayName'], props.report.ownerEmail); @@ -773,7 +778,6 @@ export default compose( prevProps.report.description === nextProps.report.description && ReportUtils.isCompletedTaskReport(prevProps.report) === ReportUtils.isCompletedTaskReport(nextProps.report) && prevProps.report.managerID === nextProps.report.managerID && - prevProps.report.managerEmail === nextProps.report.managerEmail && prevProps.shouldHideThreadDividerLine === nextProps.shouldHideThreadDividerLine && lodashGet(prevProps.report, 'total', 0) === lodashGet(nextProps.report, 'total', 0) && lodashGet(prevProps.report, 'nonReimbursableTotal', 0) === lodashGet(nextProps.report, 'nonReimbursableTotal', 0) && diff --git a/src/pages/home/report/ReportActionsView.js b/src/pages/home/report/ReportActionsView.js index f9f029881eef..2608aaf51c9b 100755 --- a/src/pages/home/report/ReportActionsView.js +++ b/src/pages/home/report/ReportActionsView.js @@ -313,10 +313,6 @@ function arePropsEqual(oldProps, newProps) { return false; } - if (lodashGet(newProps, 'report.managerEmail') !== lodashGet(oldProps, 'report.managerEmail')) { - return false; - } - if (lodashGet(newProps, 'report.total') !== lodashGet(oldProps, 'report.total')) { return false; } diff --git a/src/pages/settings/Wallet/WalletPage/WalletPage.js b/src/pages/settings/Wallet/WalletPage/WalletPage.js index 38d9722e2ed7..2387f5f23e6c 100644 --- a/src/pages/settings/Wallet/WalletPage/WalletPage.js +++ b/src/pages/settings/Wallet/WalletPage/WalletPage.js @@ -257,10 +257,6 @@ function WalletPage({bankAccountList, betas, cardList, fundList, isLoadingPaymen Navigation.navigate(source === CONST.KYC_WALL_SOURCE.ENABLE_WALLET ? ROUTES.SETTINGS_WALLET : ROUTES.SETTINGS_WALLET_TRANSFER_BALANCE); }; - useEffect(() => { - PaymentMethods.openWalletPage(); - }, []); - useEffect(() => { // If the user was previously offline, skip debouncing showing the loader if (!network.isOffline) { @@ -275,7 +271,7 @@ function WalletPage({bankAccountList, betas, cardList, fundList, isLoadingPaymen return; } PaymentMethods.openWalletPage(); - }, [network.isOffline]); + }, [network.isOffline, bankAccountList, cardList, fundList]); useEffect(() => { if (!shouldListenForResize) { diff --git a/src/pages/signin/SignInHeroImage.js b/src/pages/signin/SignInHeroImage.js index 8ed9a168b328..5acd5268d572 100644 --- a/src/pages/signin/SignInHeroImage.js +++ b/src/pages/signin/SignInHeroImage.js @@ -34,6 +34,7 @@ function SignInHeroImage(props) { loop autoPlay style={[styles.alignSelfCenter, imageSize]} + webStyle={{...styles.alignSelfCenter, ...imageSize}} /> ); } diff --git a/src/pages/workspace/WorkspaceMembersPage.js b/src/pages/workspace/WorkspaceMembersPage.js index 234f961d0470..65ec35766332 100644 --- a/src/pages/workspace/WorkspaceMembersPage.js +++ b/src/pages/workspace/WorkspaceMembersPage.js @@ -365,6 +365,7 @@ function WorkspaceMembersPage(props) { source: UserUtils.getAvatar(details.avatar, accountID), name: props.formatPhoneNumber(details.login), type: CONST.ICON_TYPE_AVATAR, + id: accountID, }, ], errors: policyMember.errors, diff --git a/src/styles/styles.ts b/src/styles/styles.ts index 589c3756042f..404c5983d7f7 100644 --- a/src/styles/styles.ts +++ b/src/styles/styles.ts @@ -3329,7 +3329,6 @@ const styles = (theme: ThemeDefault) => eReceiptAmount: { ...headlineFont, fontSize: variables.fontSizeXXXLarge, - lineHeight: variables.lineHeightXXXLarge, color: colors.green400, }, @@ -3749,21 +3748,6 @@ const styles = (theme: ThemeDefault) => reportActionItemImagesMoreCornerTriangle: { position: 'absolute', - bottom: 0, - right: 0, - width: 0, - height: 0, - borderStyle: 'solid', - borderWidth: 0, - borderBottomWidth: 40, - borderLeftWidth: 40, - borderColor: 'transparent', - borderBottomColor: theme.cardBG, - }, - - reportActionItemImagesMoreCornerTriangleHighlighted: { - borderColor: 'transparent', - borderBottomColor: theme.border, }, assignedCardsIconContainer: { diff --git a/src/styles/variables.ts b/src/styles/variables.ts index 382ed3e032d1..7bad3b1b0fb7 100644 --- a/src/styles/variables.ts +++ b/src/styles/variables.ts @@ -174,6 +174,7 @@ export default { reportActionImagesSingleImageHeight: 147, reportActionImagesDoubleImageHeight: 138, reportActionImagesMultipleImageHeight: 110, + reportActionItemImagesMoreCornerTriangleWidth: 40, bankCardWidth: 40, bankCardHeight: 26, diff --git a/tests/actions/IOUTest.js b/tests/actions/IOUTest.js index 2c6b94a2d7d5..48d3e8c558af 100644 --- a/tests/actions/IOUTest.js +++ b/tests/actions/IOUTest.js @@ -1528,8 +1528,8 @@ describe('actions/IOU', () => { ); expect(updatedChatReport).toEqual( expect.objectContaining({ - lastMessageHtml: 'undefined owes $200.00', - lastMessageText: 'undefined owes $200.00', + lastMessageHtml: `${CARLOS_EMAIL} owes $200.00`, + lastMessageText: `${CARLOS_EMAIL} owes $200.00`, }), ); resolve(); diff --git a/tests/perf-test/ReportActionCompose.perf-test.js b/tests/perf-test/ReportActionCompose.perf-test.js new file mode 100644 index 000000000000..ccc1037942b8 --- /dev/null +++ b/tests/perf-test/ReportActionCompose.perf-test.js @@ -0,0 +1,162 @@ +import {fireEvent, screen} from '@testing-library/react-native'; +import React from 'react'; +import Onyx from 'react-native-onyx'; +import {measurePerformance} from 'reassure'; +import ComposeProviders from '../../src/components/ComposeProviders'; +import {LocaleContextProvider} from '../../src/components/LocaleContextProvider'; +import OnyxProvider from '../../src/components/OnyxProvider'; +import {KeyboardStateProvider} from '../../src/components/withKeyboardState'; +import {WindowDimensionsProvider} from '../../src/components/withWindowDimensions'; +import * as Localize from '../../src/libs/Localize'; +import ONYXKEYS from '../../src/ONYXKEYS'; +import ReportActionCompose from '../../src/pages/home/report/ReportActionCompose/ReportActionCompose'; +import * as LHNTestUtils from '../utils/LHNTestUtils'; +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; + +// mock PortalStateContext +jest.mock('@gorhom/portal'); + +jest.mock('react-native-reanimated', () => ({ + ...jest.requireActual('react-native-reanimated/mock'), + useAnimatedRef: jest.fn, +})); + +jest.mock('../../src/libs/Permissions', () => ({ + canUseTasks: jest.fn(() => true), +})); + +jest.mock('@react-navigation/native', () => { + const actualNav = jest.requireActual('@react-navigation/native'); + return { + ...actualNav, + useNavigation: () => ({ + navigate: jest.fn(), + addListener: () => jest.fn(), + }), + useIsFocused: () => ({ + navigate: jest.fn(), + }), + }; +}); + +jest.mock('../../src/libs/actions/EmojiPickerAction', () => { + const actualEmojiPickerAction = jest.requireActual('../../src/libs/actions/EmojiPickerAction'); + return { + ...actualEmojiPickerAction, + emojiPickerRef: { + current: { + isEmojiPickerVisible: false, + }, + }, + showEmojiPicker: jest.fn(), + hideEmojiPicker: jest.fn(), + isActive: () => true, + }; +}); + +beforeAll(() => + Onyx.init({ + keys: ONYXKEYS, + safeEvictionKeys: [ONYXKEYS.COLLECTION.REPORT_ACTIONS], + registerStorageEventListener: () => {}, + }), +); + +// Initialize the network key for OfflineWithFeedback +beforeEach(() => { + Onyx.merge(ONYXKEYS.NETWORK, {isOffline: false}); +}); + +function ReportActionComposeWrapper() { + return ( + + jest.fn()} + reportID="1" + disabled={false} + report={LHNTestUtils.getFakeReport()} + /> + + ); +} +const mockEvent = {preventDefault: jest.fn()}; + +test('should render Composer with text input interactions', async () => { + const scenario = async () => { + // Query for the composer + const composer = await screen.findByTestId('composer'); + + expect(composer).toBeDefined(); + fireEvent.changeText(composer, '@test'); + + // Query for the suggestions + await screen.findByTestId('suggestions'); + + // scroll to hide suggestions + fireEvent.scroll(composer); + + // press to block suggestions + fireEvent.press(composer); + }; + + return waitForBatchedUpdates().then(() => measurePerformance(, {scenario})); +}); + +test('should press add attachemnt button', async () => { + const scenario = async () => { + // Query for the attachment button + const hintAttachmentButtonText = Localize.translateLocal('reportActionCompose.addAction'); + const attachmentButton = await screen.findByLabelText(hintAttachmentButtonText); + + expect(attachmentButton).toBeDefined(); + fireEvent.press(attachmentButton, mockEvent); + }; + + return waitForBatchedUpdates().then(() => measurePerformance(, {scenario})); +}); + +test('should press add emoji button', async () => { + const scenario = async () => { + // Query for the emoji button + const hintEmojiButtonText = Localize.translateLocal('reportActionCompose.emoji'); + const emojiButton = await screen.findByLabelText(hintEmojiButtonText); + + expect(emojiButton).toBeDefined(); + fireEvent.press(emojiButton); + }; + + return waitForBatchedUpdates().then(() => measurePerformance(, {scenario})); +}); + +test('should press send message button', async () => { + const scenario = async () => { + // Query for the send button + const hintSendButtonText = Localize.translateLocal('common.send'); + const sendButton = await screen.findByLabelText(hintSendButtonText); + + expect(sendButton).toBeDefined(); + fireEvent.press(sendButton); + }; + + return waitForBatchedUpdates().then(() => measurePerformance(, {scenario})); +}); + +test('render composer with attachement modal interactions', async () => { + const scenario = async () => { + const hintAddAttachmentButtonText = Localize.translateLocal('reportActionCompose.addAttachment'); + const hintAssignTaskButtonText = Localize.translateLocal('newTaskPage.assignTask'); + const hintSplitBillButtonText = Localize.translateLocal('iou.splitBill'); + + // Query for the attachment modal items + const addAttachmentButton = await screen.findByLabelText(hintAddAttachmentButtonText); + fireEvent.press(addAttachmentButton, mockEvent); + + const splitBillButton = await screen.findByLabelText(hintSplitBillButtonText); + fireEvent.press(splitBillButton, mockEvent); + + const assignTaskButton = await screen.findByLabelText(hintAssignTaskButtonText); + fireEvent.press(assignTaskButton, mockEvent); + }; + + return waitForBatchedUpdates().then(() => measurePerformance(, {scenario})); +});