diff --git a/.github/ISSUE_TEMPLATE/Accessibility.md b/.github/ISSUE_TEMPLATE/Accessibility.md index 36a64aa25b43..1323e2c17e78 100644 --- a/.github/ISSUE_TEMPLATE/Accessibility.md +++ b/.github/ISSUE_TEMPLATE/Accessibility.md @@ -34,7 +34,7 @@ What can we do to fix the issue? -Which of our officially supported platforms is this issue occurring on? +Which of our officially supported platforms is this issue occurring on? Please only tick the box if you have provided a screen-recording in the thread for each platform: - [ ] Android / native - [ ] Android / Chrome - [ ] iOS / native diff --git a/.github/workflows/deployExpensifyHelp.yml b/.github/workflows/deployExpensifyHelp.yml index 11f4897ab322..ca7345ef9462 100644 --- a/.github/workflows/deployExpensifyHelp.yml +++ b/.github/workflows/deployExpensifyHelp.yml @@ -2,6 +2,10 @@ name: Deploy ExpensifyHelp on: + # Runs on pushes targeting the default branch + push: + branches: ["main"] + # Allows you to run this workflow manually from the Actions tab workflow_dispatch: diff --git a/.well-known/apple-app-site-association b/.well-known/apple-app-site-association index c871764117ed..a9e2b0383691 100644 --- a/.well-known/apple-app-site-association +++ b/.well-known/apple-app-site-association @@ -68,6 +68,10 @@ "/": "/workspace/*", "comment": "Workspace Details" }, + { + "/": "/get-assistance/*", + "comment": "Get Assistance Pages" + }, { "/": "/teachersunite/*", "comment": "Teachers Unite!" diff --git a/android/app/build.gradle b/android/app/build.gradle index 7201618a2c1c..afe24fc37700 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 1001037301 - versionName "1.3.73-1" + versionCode 1001037403 + versionName "1.3.74-3" } flavorDimensions "default" diff --git a/android/app/src/main/java/com/expensify/chat/customairshipextender/CustomNotificationProvider.java b/android/app/src/main/java/com/expensify/chat/customairshipextender/CustomNotificationProvider.java index 0ba77f809b19..c60476ad3f0a 100644 --- a/android/app/src/main/java/com/expensify/chat/customairshipextender/CustomNotificationProvider.java +++ b/android/app/src/main/java/com/expensify/chat/customairshipextender/CustomNotificationProvider.java @@ -106,7 +106,8 @@ protected NotificationCompat.Builder onExtendBuilder(@NonNull Context context, @ if (payload.containsKey(ONYX_DATA_KEY)) { Objects.requireNonNull(payload.get(ONYX_DATA_KEY)).isNull(); Log.d(TAG, "payload contains onxyData"); - applyMessageStyle(context, builder, payload, arguments.getNotificationId()); + String alert = message.getExtra(PushMessage.EXTRA_ALERT); + applyMessageStyle(context, builder, payload, arguments.getNotificationId(), alert); } } catch (Exception e) { Log.e(TAG, "Failed to parse conversation, falling back to default notification style. SendID=" + message.getSendId(), e); @@ -163,7 +164,7 @@ public Bitmap getCroppedBitmap(Bitmap bitmap) { * @param payload Notification payload, which contains all the data we need to build the notifications. * @param notificationID Current notification ID */ - private void applyMessageStyle(@NonNull Context context, NotificationCompat.Builder builder, JsonMap payload, int notificationID) { + private void applyMessageStyle(@NonNull Context context, NotificationCompat.Builder builder, JsonMap payload, int notificationID, String alert) { long reportID = payload.get("reportID").getLong(-1); if (reportID == -1) { return; @@ -181,7 +182,9 @@ private void applyMessageStyle(@NonNull Context context, NotificationCompat.Buil String name = messageData.get("person").getList().get(0).getMap().get("text").getString(); String avatar = messageData.get("avatar").getString(); String accountID = Integer.toString(messageData.get("actorAccountID").getInt(-1)); - String message = messageData.get("message").getList().get(0).getMap().get("text").getString(); + + // Use the formatted alert message from the backend. Otherwise fallback on the message in the Onyx data. + String message = alert != null ? alert : messageData.get("message").getList().get(0).getMap().get("text").getString(); String conversationName = payload.get("roomName") == null ? "" : payload.get("roomName").getString(""); // Retrieve or create the Person object who sent the latest report comment diff --git a/assets/images/expensify-card.svg b/assets/images/expensify-card.svg new file mode 100644 index 000000000000..f95e3ed20288 --- /dev/null +++ b/assets/images/expensify-card.svg @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/contributingGuides/CONTRIBUTING.md b/contributingGuides/CONTRIBUTING.md index b97d04b95e10..621673e5a487 100644 --- a/contributingGuides/CONTRIBUTING.md +++ b/contributingGuides/CONTRIBUTING.md @@ -47,7 +47,7 @@ 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 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. 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). diff --git a/docs/404.html b/docs/404.html index 1773388c6923..4338293218cc 100644 --- a/docs/404.html +++ b/docs/404.html @@ -1,8 +1,8 @@ --- permalink: /404.html --- -
- +
+ Hmm it's not here...
That page is nowhere to be found.
diff --git a/docs/_data/_routes.yml b/docs/_data/_routes.yml index 20582b6b8c7e..c6733ac11715 100644 --- a/docs/_data/_routes.yml +++ b/docs/_data/_routes.yml @@ -1,142 +1,144 @@ home: href: home title: Welcome to ExpensifyHelp! - description: Questions? Find the answers by clicking a Category or using the search bar located in the left-hand menu. + description: Find answers by using the search bar in the left-hand menu or by clicking the version of Expensify you're using to access the relevant resources. platforms: - href: expensify-classic title: Expensify Classic hub-title: Expensify Classic - Help & Resources - url: expensify.com - description: Your account settings will look something like this - image: /assets/images/paper-airplane.svg + hub-description: Questions? Find the answers by clicking a Category or using the search bar located in the left-hand menu. + url: www.expensify.com + description: "Your account settings look like this:" + image: /assets/images/settings-old-dot.svg # Hubs are comprised of sections and articles. Sections contain multiple related articles, but there can be standalone articles as well hubs: + - href: getting-started + title: Getting Started + icon: /assets/images/accounting.svg + description: From setting up your account to ensuring you get the most out of Expensify’s suite of features, click here to get started on streamlining your expense management journey. + - href: account-settings title: Account Settings icon: /assets/images/gears.svg - description: With only a couple of clicks, split bills with your friends or coworkers. + description: Discover how to personalize your profile, add secondary logins, and grant delegated access to employees with our comprehensive guide on Account Settings. - href: bank-accounts-and-credit-cards title: Bank Accounts & Credit Cards icon: /assets/images/bank-card.svg - description: Request money for work expenses, bills, or a night out with friends. + description: Find out how to connect Expensify to your financial institutions, track credit card transactions, and best practices for reconciling company cards. - href: billing-and-subscriptions title: Billing & Subscriptions icon: /assets/images/money-wings.svg - description: Best practices for how to best deploy Expensify for your business + description: Here is where you can review Expensify's billing and subscription options, plan types, and payment methods. - href: expense-and-report-features title: Expense & Report Features icon: /assets/images/money-receipt.svg - description: Everything else you're looking for is right here. + description: From enabling automatic expense auditing to tracking attendees, here is where you can review tips and tutorials to streamline expense management. - href: expensify-card title: Expensify Card icon: /assets/images/hand-card.svg - description: Request money for work expenses, bills, or a night out with friends. + description: Explore how the Expensify Card combines convenience and security to enhance everyday business transactions. Discover how to apply for, oversee, and maximize your card perks here. - href: exports title: Exports icon: /assets/images/monitor.svg - description: Best practices for how to best deploy Expensify for your business + description: From exporting reports to creating custom templates, here is where you can learn more about Expensify's versatile export options. - href: get-paid-back title: Get Paid Back - description: Everything else you're looking for is right here. icon: /assets/images/money-into-wallet.svg - - - href: getting-started - title: Getting Started - description: Everything else you're looking for is right here. - icon: /assets/images/accounting.svg + description: Whether you submit an expense report or an invoice, find out here how to ensure a smooth and timely payback process every time. - href: integrations title: Integrations - description: Everything else you're looking for is right here. icon: /assets/images/workflow.svg + description: Enhance Expensify’s capabilities by integrating it with your accounting or HR software. Here is where you can learn more about creating a synchronized financial management ecosystem. - href: manage-employees-and-report-approvals title: Manage Employees & Report Approvals icon: /assets/images/envelope-receipt.svg - description: Everything else you're looking for is right here. + description: Master the art of overseeing employees and reports by utilizing Expensify’s automation features and approval workflows. - href: policy-and-domain-settings - title: Policy & Domain Setting + title: Policy & Domain Settings icon: /assets/images/shield.svg - description: Everything else you're looking for is right here. + description: Discover how to set up and manage policies, define user permissions, and implement compliance rules to maintain a secure and compliant financial management landscape. - href: send-payments title: Send Payments icon: /assets/images/money-wings.svg - description: Everything else you're looking for is right here. + description: Uncover step-by-step guidance on sending direct reimbursements to employees, paying an invoice to a vendor, and utilizing third-party payment options. - href: new-expensify title: New Expensify hub-title: New Expensify - Help & Resources + hub-description: Questions? Find the answers by clicking a Category or using the search bar located in the left-hand menu. url: new.expensify.com - description: Your account settings will look something like this - image: /assets/images/paper-airplane.svg + description: "Your account settings look like this:" + image: /assets/images/settings-new-dot.svg hubs: + - href: getting-started + title: Getting Started + icon: /assets/images/accounting.svg + description: From setting up your account to ensuring you get the most out of Expensify’s suite of features, click here to get started on streamlining your expense management journey. + - href: account-settings title: Account Settings icon: /assets/images/gears.svg - description: With only a couple of clicks, split bills with your friends or coworkers. + description: Discover how to personalize your profile, add secondary logins, and grant delegated access to employees with our comprehensive guide on Account Settings. - href: bank-accounts-and-credit-cards title: Bank Accounts & Credit Cards icon: /assets/images/bank-card.svg - description: description + description: Find out how to connect Expensify to your financial institutions, track credit card transactions, and best practices for reconciling company cards. - href: billing-and-plan-types title: Billing & Plan Types icon: /assets/images/money-wings.svg - description: description + description: Here is where you can review Expensify's billing and subscription options, plan types, and payment methods. - href: expense-and-report-features title: Expense & Report Features icon: /assets/images/money-receipt.svg - description: description + description: From enabling automatic expense auditing to tracking attendees, here is where you can review tips and tutorials to streamline expense management. - href: expensify-card title: Expensify Card icon: /assets/images/hand-card.svg - description: description + description: Explore how the Expensify Card combines convenience and security to enhance everyday business transactions. Discover how to apply for, oversee, and maximize your card perks here. - href: exports title: Exports icon: /assets/images/monitor.svg - description: description + description: From exporting reports to creating custom templates, here is where you can learn more about Expensify's versatile export options. - href: get-paid-back title: Get Paid Back icon: /assets/images/money-into-wallet.svg - description: description - - - href: getting-started - title: Getting Started - icon: /assets/images/accounting.svg - description: description + description: Whether you submit an expense report or an invoice, find out here how to ensure a smooth and timely payback process every time. - href: integrations title: Integrations icon: /assets/images/workflow.svg - description: description + description: Enhance Expensify’s capabilities by integrating it with your accounting or HR software. Here is where you can learn more about creating a synchronized financial management ecosystem. - href: manage-employees-and-report-approvals title: Manage Employees & Report Approvals icon: /assets/images/envelope-receipt.svg - description: description + description: Master the art of overseeing employees and reports by utilizing Expensify’s automation features and approval workflows. - href: send-payments title: Send Payments icon: /assets/images/money-wings.svg - description: description. + description: Uncover step-by-step guidance on sending direct reimbursements to employees, paying an invoice to a vendor, and utilizing third-party payment options. - href: workspace-and-domain-settings title: Workspace & Domain Settings icon: /assets/images/shield.svg - description: description. + description: Discover how to set up and manage your workspace, define user permissions, and implement domain-level rules. diff --git a/docs/_includes/article-card.html b/docs/_includes/article-card.html index b6d8998c13ef..b66affebb4ae 100644 --- a/docs/_includes/article-card.html +++ b/docs/_includes/article-card.html @@ -1,6 +1,6 @@ - +
-

{{ include.title }}

+

{{ include.title }}

diff --git a/docs/_includes/hub-card.html b/docs/_includes/hub-card.html index b5188bda7670..859ee0004394 100644 --- a/docs/_includes/hub-card.html +++ b/docs/_includes/hub-card.html @@ -1,8 +1,8 @@ {% assign hub = include.hub %} {% assign platform = include.platform %} - +
-
+
{{ hub.href }}
diff --git a/docs/_includes/hub.html b/docs/_includes/hub.html index 6b0b0e590b19..7f1e25243c09 100644 --- a/docs/_includes/hub.html +++ b/docs/_includes/hub.html @@ -12,27 +12,13 @@

{{ hub.description }}

-{% if hub.articles %} -
-
- {% for article in hub.articles %} - {% include article-card.html hub=hub.href href=article.href title=article.title platform=activePlatform %} - {% endfor %} -
-
-{% endif %} - -{% for section in hub.sections %} -
-

- {{ section.title }} -

- -
- {% for article in section.articles %} - {% assign article_href = section.href | append: '/' | append: article.href %} - {% include article-card.html hub=hub.href href=article_href title=article.title platform=activePlatform %} - {% endfor %} -
-
-{% endfor %} +
+
+ {% for section in hub.sections %} + {% include section-card.html platform=activePlatform hub=hub.href section=section.href title=section.title %} + {% endfor %} + {% for article in hub.articles %} + {% include article-card.html hub=hub.href href=article.href title=article.title platform=activePlatform %} + {% endfor %} +
+
diff --git a/docs/_includes/lhn-template.html b/docs/_includes/lhn-template.html index 015c8211e5b2..80302f33f52e 100644 --- a/docs/_includes/lhn-template.html +++ b/docs/_includes/lhn-template.html @@ -1,5 +1,13 @@ -{% assign activePlatform = page.url | replace:'/',' ' | truncatewords: 1 | remove:'...' %} -{% assign activeHub = page.url | remove: activePlatform | remove: "/hubs/" | remove: "/" | remove: ".html" %} +{% assign urlArray = page.url | replace: '/', ' ' | split: " " %} + +{% assign activePlatform = urlArray[0] %} +{% assign platform = site.data.routes.platforms | where: "href", activePlatform | first %} + +{% assign activeHub = urlArray[2] %} +{% assign hub = platform.hubs | where: "href", activeHub | first %} + +{% assign activeSection = urlArray[3] | remove: ".html" %} +{% assign section = hub.sections | where: "href", activeSection | first %}
  • - + {% for hub in platform.hubs %}
      {% if hub.href == activeHub %} - +
        - {% for article in hub.articles %} - {% include lhn-article-link.html platform=activePlatform hub=hub.href href=article.href title=article.title %} - {% endfor %} - {% for section in hub.sections %}
      • - {{ section.title }} -
          - {% for article in section.articles %} - {% assign article_href = section.href | append: '/' | append: article.href %} - {% include lhn-article-link.html platform=activePlatform hub=hub.href href=article_href title=article.title %} - {% endfor %} -
        + {% if section.href == activeSection %} + + + {{ section.title }} + +
          + {% for article in section.articles %} + {% assign article_href = section.href | append: '/' | append: article.href %} + {% include lhn-article-link.html platform=activePlatform hub=hub.href href=article_href title=article.title %} + {% endfor %} +
        + {% else %} + + + {{ section.title }} + + {% endif %} +
      • {% endfor %} + + {% for article in hub.articles %} + {% include lhn-article-link.html platform=activePlatform hub=hub.href href=article.href title=article.title %} + {% endfor %}
      {% else %}
    • diff --git a/docs/_includes/platform-card.html b/docs/_includes/platform-card.html index d56a234a5c14..7123f18a679a 100644 --- a/docs/_includes/platform-card.html +++ b/docs/_includes/platform-card.html @@ -1,13 +1,22 @@ {% assign platform = site.data.routes.platforms | where: "href", include.href | first %} - +
      -
      - {{ platform.href }} -
      -

      {{ platform.title }}

      -

      {{ platform.description }}

      +
      +
      +

      {{ platform.title }}

      +

      {{ platform.url }}

      +
      +
      + +
      +
      + +

      {{ platform.description }}

      +
      +
      + {{ platform.href }}
      diff --git a/docs/_includes/platform.html b/docs/_includes/platform.html index f3867ee4f5b7..6aa88f9208ae 100644 --- a/docs/_includes/platform.html +++ b/docs/_includes/platform.html @@ -3,9 +3,9 @@

      {{ platform.hub-title }}

      -

      {{ site.data.routes.home.description }}

      +

      {{ platform.hub-description }}

      -
      +
      {% for hub in platform.hubs %} {% include hub-card.html hub=hub platform=selectedPlatform %} {% endfor %} diff --git a/docs/_includes/section-card.html b/docs/_includes/section-card.html new file mode 100644 index 000000000000..9500983d4d28 --- /dev/null +++ b/docs/_includes/section-card.html @@ -0,0 +1,8 @@ + +
      +

      {{ include.title }}

      +
      +
      + +
      +
      diff --git a/docs/_includes/section.html b/docs/_includes/section.html new file mode 100644 index 000000000000..786e7d997462 --- /dev/null +++ b/docs/_includes/section.html @@ -0,0 +1,23 @@ +{% assign urlArray = page.url | replace: '/', ' ' | split: " " %} + +{% assign activePlatform = urlArray[0] %} +{% assign platform = site.data.routes.platforms | where: "href", activePlatform | first %} + +{% assign activeHub = urlArray[2] %} +{% assign hub = platform.hubs | where: "href", activeHub | first %} + +{% assign activeSection = urlArray[3] | remove: ".html" %} +{% assign section = hub.sections | where: "href", activeSection | first %} + +

      + {{ section.title }} +

      + +
      +
      + {% for article in section.articles %} + {% assign article_href = section.href | append: '/' | append: article.href %} + {% include article-card.html hub=hub.href href=article_href title=article.title platform=activePlatform %} + {% endfor %} +
      +
      diff --git a/docs/_sass/_main.scss b/docs/_sass/_main.scss index bc9d19bfca11..3ad2276713da 100644 --- a/docs/_sass/_main.scss +++ b/docs/_sass/_main.scss @@ -299,7 +299,6 @@ button { } .selected { - cursor: auto; font-weight: bold; color: $color-text; } @@ -457,6 +456,15 @@ button { } } + +.platform-cards-group { + @extend .cards-group; + + @include breakpoint($breakpoint-desktop) { + grid-template-columns: 33.33% 33.33% 33.33%; + } +} + .card { display: flex; flex-wrap: nowrap; @@ -476,21 +484,55 @@ button { flex-basis:100%; } - .left-icon { + .body { display: flex; - align-items: center; - padding-right: 28px; + flex-wrap: nowrap; + flex-direction: column; + flex-grow: 2; + } - img { - width: 64px; - } + h3.title { + font-family: "ExpensifyNewKansas", "Helvetica Neue", "Helvetica", Arial, sans-serif; } + h3.title, + h4.title { + padding: 0; + margin: 0; + } + + p.description, + p.url { + margin: 0; + font-weight: normal; + } +} + +.article-card { + @extend .card; + .right-icon { display: flex; align-items: center; padding-left: 16px; } +} + +.platform-card { + @extend .card; + .row { + flex-direction: column; + } + + .platform-screenshot { + display: flex; + align-items: center; + + img { + border-radius: 12px; + width: 100%; + } + } .submit-button { display: flex; @@ -505,27 +547,62 @@ button { } .body { - display: flex; - flex-wrap: nowrap; - flex-direction: column; - flex-grow: 2; - } + .header { + display: flex; + align-items: center; + justify-content: space-between; - h3.title { - padding: 0; - margin: 0; + .select-button { + display: flex; + .success { + align-items: flex-end; + font-size: 0.8em; + } + } + } + } + h3.title, + h4.title { &.with-margin { margin: 0 0 4px 0; } } - + h3.title { + font-size: 1.4em; + font-weight: normal; + } p.description { + padding: 20px 0 20px 0; + } + + p.url { padding: 0; - margin: 0; + font-size: 0.8em; + color: $color-gray-label; + } +} + +.hub-card { + @extend .card; + padding: 24px; + + .row { + flex-direction: column; + } + + h3.title { + font-size: 1.2em; font-weight: normal; + &.with-margin { + margin: 20px 0 8px 0; + } + } + + p.description { + padding: 0; &.with-min-height { min-height: 68px; @@ -564,30 +641,13 @@ button { } .centered-content { - height: 240px; + width: 100%; + height: calc(100vh - 56px); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; text-align: center; - font-size: larger; - position: absolute; - top: calc((100vh - 240px) / 2); - - width: 380px; - right: calc((100vw - 380px) / 2); - @include breakpoint($breakpoint-tablet) { - width: 500px; - right: calc((100vw - 500px) / 2); - } - - &.with-lhn { - right: calc((100vw - 380px) / 2); - - @include breakpoint($breakpoint-tablet) { - right: calc((100vw - 320px - 500px ) / 2); - } - - @include breakpoint($breakpoint-desktop) { - right: calc((100vw - 420px - 500px) / 2); - } - } div { margin-top: 8px; diff --git a/docs/articles/expensify-classic/account-settings/Account-Access.md b/docs/articles/expensify-classic/account-settings/Account-Access.md index f04b45c42639..b3126201715f 100644 --- a/docs/articles/expensify-classic/account-settings/Account-Access.md +++ b/docs/articles/expensify-classic/account-settings/Account-Access.md @@ -2,4 +2,4 @@ title: Account Access description: Account Access --- -## Resources Coming Soon! +## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/account-settings/Close-Account.md b/docs/articles/expensify-classic/account-settings/Close-Account.md index cf5052fa56f1..5e18490fc357 100644 --- a/docs/articles/expensify-classic/account-settings/Close-Account.md +++ b/docs/articles/expensify-classic/account-settings/Close-Account.md @@ -2,4 +2,4 @@ title: Close Account description: Close Account --- -## Resources Coming Soon! +## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/account-settings/Merge-Accounts.md b/docs/articles/expensify-classic/account-settings/Merge-Accounts.md index 1c5f22478e17..073c74346d75 100644 --- a/docs/articles/expensify-classic/account-settings/Merge-Accounts.md +++ b/docs/articles/expensify-classic/account-settings/Merge-Accounts.md @@ -2,4 +2,4 @@ title: Merge Accounts description: Merge Accounts --- -## Resources Coming Soon! +## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/account-settings/Preferences.md b/docs/articles/expensify-classic/account-settings/Preferences.md index a3e53e1177a1..532da4d8a986 100644 --- a/docs/articles/expensify-classic/account-settings/Preferences.md +++ b/docs/articles/expensify-classic/account-settings/Preferences.md @@ -2,4 +2,4 @@ title: Preferences description: Preferences --- -## Resources Coming Soon! +## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/account-settings/Profile-Settings.md b/docs/articles/expensify-classic/account-settings/Profile-Settings.md index bdc18036a46e..3b2a0b830926 100644 --- a/docs/articles/expensify-classic/account-settings/Profile-Settings.md +++ b/docs/articles/expensify-classic/account-settings/Profile-Settings.md @@ -2,4 +2,4 @@ title: Profile Settings description: Profile Settings --- -## Resources Coming Soon! +## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/bank-accounts-and-credit-cards/Business-Bank-Accounts-AUS.md b/docs/articles/expensify-classic/bank-accounts-and-credit-cards/Business-Bank-Accounts-AUS.md deleted file mode 100644 index 44488defcd67..000000000000 --- a/docs/articles/expensify-classic/bank-accounts-and-credit-cards/Business-Bank-Accounts-AUS.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Business Bank Accounts - AUS -description: Business Bank Accounts - AUS ---- -## Resources Coming Soon! diff --git a/docs/articles/expensify-classic/bank-accounts-and-credit-cards/Deposit-Accounts-AUS.md b/docs/articles/expensify-classic/bank-accounts-and-credit-cards/Deposit-Accounts-AUS.md deleted file mode 100644 index dba02f6fc52c..000000000000 --- a/docs/articles/expensify-classic/bank-accounts-and-credit-cards/Deposit-Accounts-AUS.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Deposit Accounts - AUS -description: Deposit Accounts - AUS ---- -## Resources Coming Soon! diff --git a/docs/articles/expensify-classic/bank-accounts-and-credit-cards/Global-Reimbursement.md b/docs/articles/expensify-classic/bank-accounts-and-credit-cards/Global-Reimbursement.md index 40bdfb7741ab..073d3a9bd700 100644 --- a/docs/articles/expensify-classic/bank-accounts-and-credit-cards/Global-Reimbursement.md +++ b/docs/articles/expensify-classic/bank-accounts-and-credit-cards/Global-Reimbursement.md @@ -2,4 +2,4 @@ title: Global Reimbursement description: Global Reimbursement --- -## Resources Coming Soon! +## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/bank-accounts-and-credit-cards/Personal-Credit-Cards.md b/docs/articles/expensify-classic/bank-accounts-and-credit-cards/Personal-Credit-Cards.md index 016ca90ee7f7..f89729b69586 100644 --- a/docs/articles/expensify-classic/bank-accounts-and-credit-cards/Personal-Credit-Cards.md +++ b/docs/articles/expensify-classic/bank-accounts-and-credit-cards/Personal-Credit-Cards.md @@ -2,4 +2,4 @@ title: Personal Credit Cards description: Personal Credit Cards --- -## Resources Coming Soon! +## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/bank-accounts-and-credit-cards/business-bank-accounts/Business-Bank-Accounts-AUD.md b/docs/articles/expensify-classic/bank-accounts-and-credit-cards/business-bank-accounts/Business-Bank-Accounts-AUD.md new file mode 100644 index 000000000000..7c789942a2b3 --- /dev/null +++ b/docs/articles/expensify-classic/bank-accounts-and-credit-cards/business-bank-accounts/Business-Bank-Accounts-AUD.md @@ -0,0 +1,5 @@ +--- +title: Business Bank Accounts - AUD +description: Business Bank Accounts - AUD +--- +## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/bank-accounts-and-credit-cards/Business-Bank-Accounts-USD.md b/docs/articles/expensify-classic/bank-accounts-and-credit-cards/business-bank-accounts/Business-Bank-Accounts-USD.md similarity index 76% rename from docs/articles/expensify-classic/bank-accounts-and-credit-cards/Business-Bank-Accounts-USD.md rename to docs/articles/expensify-classic/bank-accounts-and-credit-cards/business-bank-accounts/Business-Bank-Accounts-USD.md index 218d6dcd1efa..375b00d62eac 100644 --- a/docs/articles/expensify-classic/bank-accounts-and-credit-cards/Business-Bank-Accounts-USD.md +++ b/docs/articles/expensify-classic/bank-accounts-and-credit-cards/business-bank-accounts/Business-Bank-Accounts-USD.md @@ -2,4 +2,4 @@ title: Business Bank Accounts - USD description: Business Bank Accounts - USD --- -## Resources Coming Soon! +## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/ANZ.md b/docs/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/ANZ.md index 6bfc7b14c09a..7e6a76ecee24 100644 --- a/docs/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/ANZ.md +++ b/docs/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/ANZ.md @@ -2,5 +2,5 @@ title: ANZ description: A guide to integrate with your ANZ card --- -## Resources Coming Soon! +## Resource Coming Soon! Coming Soon!! 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 index 7d5ad7bf0315..a060e37146a5 100644 --- 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 @@ -2,4 +2,4 @@ title: Brex description: Brex --- -## Resources Coming Soon! +## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/CSV-Import.md b/docs/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/CSV-Import.md index db68d4431a3a..6debce6240ff 100644 --- a/docs/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/CSV-Import.md +++ b/docs/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/CSV-Import.md @@ -2,4 +2,4 @@ title: CSV Import description: CSV Import --- -## Resources Coming Soon! +## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/Commercial-Card-Feeds.md b/docs/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/Commercial-Card-Feeds.md index e49d0d61855c..25d11561755d 100644 --- a/docs/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/Commercial-Card-Feeds.md +++ b/docs/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/Commercial-Card-Feeds.md @@ -2,4 +2,4 @@ title: Commercial Card Feeds description: Commercial Card Feeds --- -## Resources Coming Soon! +## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/Connect-Company-Cards.md b/docs/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/Connect-Company-Cards.md index ecd4fc0a6538..112c3b9617c9 100644 --- a/docs/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/Connect-Company-Cards.md +++ b/docs/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/Connect-Company-Cards.md @@ -2,4 +2,4 @@ title: Connect Company Cards description: Connect Company Cards --- -## Resources Coming Soon! +## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/Direct-Bank-Connections.md b/docs/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/Direct-Bank-Connections.md index 6775b2684b61..f1d939ca9c89 100644 --- a/docs/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/Direct-Bank-Connections.md +++ b/docs/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/Direct-Bank-Connections.md @@ -2,4 +2,4 @@ title: Direct Bank Connections description: Direct Bank Connections --- -## Resources Coming Soon! +## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/Export-To-GL-Accounts.md b/docs/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/Export-To-GL-Accounts.md index 58485888b921..85b534338b53 100644 --- a/docs/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/Export-To-GL-Accounts.md +++ b/docs/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/Export-To-GL-Accounts.md @@ -2,4 +2,4 @@ title: Export to GL Accounts description: Export to GL Accounts --- -## Resources Coming Soon! +## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/Reconciliation.md b/docs/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/Reconciliation.md index be400ee2c13c..b51329f2a803 100644 --- a/docs/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/Reconciliation.md +++ b/docs/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/Reconciliation.md @@ -2,4 +2,4 @@ title: Reconciliation description: Reconciliation --- -## Resources Coming Soon! +## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/Troubleshooting.md b/docs/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/Troubleshooting.md index d9e0d1bb994b..e3d1307e6a05 100644 --- a/docs/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/Troubleshooting.md +++ b/docs/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/Troubleshooting.md @@ -2,4 +2,4 @@ title: Troubleshooting description: Troubleshooting --- -## Resources Coming Soon! +## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/bank-accounts-and-credit-cards/deposit-accounts/Deposit-Accounts-AUS.md b/docs/articles/expensify-classic/bank-accounts-and-credit-cards/deposit-accounts/Deposit-Accounts-AUS.md new file mode 100644 index 000000000000..61e6dfd95e38 --- /dev/null +++ b/docs/articles/expensify-classic/bank-accounts-and-credit-cards/deposit-accounts/Deposit-Accounts-AUS.md @@ -0,0 +1,5 @@ +--- +title: Deposit Accounts - AUD +description: Deposit Accounts - AUD +--- +## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/bank-accounts-and-credit-cards/Deposit-Accounts-USD.md b/docs/articles/expensify-classic/bank-accounts-and-credit-cards/deposit-accounts/Deposit-Accounts-USD.md similarity index 74% rename from docs/articles/expensify-classic/bank-accounts-and-credit-cards/Deposit-Accounts-USD.md rename to docs/articles/expensify-classic/bank-accounts-and-credit-cards/deposit-accounts/Deposit-Accounts-USD.md index 8d3fe6e51484..19010be95980 100644 --- a/docs/articles/expensify-classic/bank-accounts-and-credit-cards/Deposit-Accounts-USD.md +++ b/docs/articles/expensify-classic/bank-accounts-and-credit-cards/deposit-accounts/Deposit-Accounts-USD.md @@ -2,4 +2,4 @@ title: Deposit Accounts - USD description: Deposit Accounts - USD --- -## Resources Coming Soon! +## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/billing-and-subscriptions/Annual-Subscription.md b/docs/articles/expensify-classic/billing-and-subscriptions/Annual-Subscription.md index c80a0d57400d..8e2aa7d4a377 100644 --- a/docs/articles/expensify-classic/billing-and-subscriptions/Annual-Subscription.md +++ b/docs/articles/expensify-classic/billing-and-subscriptions/Annual-Subscription.md @@ -2,4 +2,4 @@ title: Annual Subscription description: Annual Subscription --- -## Resources Coming Soon! +## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/billing-and-subscriptions/Billing-Owner.md b/docs/articles/expensify-classic/billing-and-subscriptions/Billing-Owner.md index 590fbc78007e..acb29d91e1d8 100644 --- a/docs/articles/expensify-classic/billing-and-subscriptions/Billing-Owner.md +++ b/docs/articles/expensify-classic/billing-and-subscriptions/Billing-Owner.md @@ -2,4 +2,4 @@ title: Billing-Owner description: Billing-Owner --- -## Resources Coming Soon! \ No newline at end of file +## Resource Coming Soon! \ No newline at end of file diff --git a/docs/articles/expensify-classic/billing-and-subscriptions/Change-Plan-Or-Subscription.md b/docs/articles/expensify-classic/billing-and-subscriptions/Change-Plan-Or-Subscription.md index 2f593625a7d5..8ce4283dd17d 100644 --- a/docs/articles/expensify-classic/billing-and-subscriptions/Change-Plan-Or-Subscription.md +++ b/docs/articles/expensify-classic/billing-and-subscriptions/Change-Plan-Or-Subscription.md @@ -2,4 +2,4 @@ title: Change Plan or Subscription description: Change Plan or Subscription --- -## Resources Coming Soon! +## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/billing-and-subscriptions/Consolidated-Domain-Billing.md b/docs/articles/expensify-classic/billing-and-subscriptions/Consolidated-Domain-Billing.md index de6ec4a4a466..24edc553bd29 100644 --- a/docs/articles/expensify-classic/billing-and-subscriptions/Consolidated-Domain-Billing.md +++ b/docs/articles/expensify-classic/billing-and-subscriptions/Consolidated-Domain-Billing.md @@ -2,4 +2,4 @@ title: Consolidated Domain Billing description: Consolidated Domain Billing --- -## Resources Coming Soon! +## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/billing-and-subscriptions/Free-Trial.md b/docs/articles/expensify-classic/billing-and-subscriptions/Free-Trial.md index 8a7b7edd19d9..e08aaa3d6094 100644 --- a/docs/articles/expensify-classic/billing-and-subscriptions/Free-Trial.md +++ b/docs/articles/expensify-classic/billing-and-subscriptions/Free-Trial.md @@ -2,4 +2,4 @@ title: Free Trial description: Free Trial --- -## Resources Coming Soon! +## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/billing-and-subscriptions/Individual-Subscription.md b/docs/articles/expensify-classic/billing-and-subscriptions/Individual-Subscription.md index d6be489a1146..1ace758978aa 100644 --- a/docs/articles/expensify-classic/billing-and-subscriptions/Individual-Subscription.md +++ b/docs/articles/expensify-classic/billing-and-subscriptions/Individual-Subscription.md @@ -2,4 +2,4 @@ title: Individual Subscription description: Individual Subscription --- -## Resources Coming Soon! +## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/billing-and-subscriptions/Overview.md b/docs/articles/expensify-classic/billing-and-subscriptions/Overview.md index 3352c72167cd..963186916f01 100644 --- a/docs/articles/expensify-classic/billing-and-subscriptions/Overview.md +++ b/docs/articles/expensify-classic/billing-and-subscriptions/Overview.md @@ -2,4 +2,4 @@ title: Overview description: Overview --- -## Resources Coming Soon! +## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/billing-and-subscriptions/Pay-Per-Use-Subscription.md b/docs/articles/expensify-classic/billing-and-subscriptions/Pay-Per-Use-Subscription.md index be431a287557..77aca2a01678 100644 --- a/docs/articles/expensify-classic/billing-and-subscriptions/Pay-Per-Use-Subscription.md +++ b/docs/articles/expensify-classic/billing-and-subscriptions/Pay-Per-Use-Subscription.md @@ -2,4 +2,4 @@ title: Pay-per-use Subscription description: Pay-per-use Subscription --- -## Resources Coming Soon! +## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/billing-and-subscriptions/Payment-Card.md b/docs/articles/expensify-classic/billing-and-subscriptions/Payment-Card.md index 91c5d4e91eda..41a1fb96f56f 100644 --- a/docs/articles/expensify-classic/billing-and-subscriptions/Payment-Card.md +++ b/docs/articles/expensify-classic/billing-and-subscriptions/Payment-Card.md @@ -2,4 +2,4 @@ title: Payment Card description: Payment Card --- -## Resources Coming Soon! +## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/billing-and-subscriptions/Tax-Exempt.md b/docs/articles/expensify-classic/billing-and-subscriptions/Tax-Exempt.md index c8f781cbd59b..c4948b5b3083 100644 --- a/docs/articles/expensify-classic/billing-and-subscriptions/Tax-Exempt.md +++ b/docs/articles/expensify-classic/billing-and-subscriptions/Tax-Exempt.md @@ -2,4 +2,4 @@ title: Tax Exempt description: Tax Exempt --- -## Resources Coming Soon! +## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/expense-and-report-features/Attendee-Tracking.md b/docs/articles/expensify-classic/expense-and-report-features/Attendee-Tracking.md index bc7fbdfe84aa..a5b0b26b2610 100644 --- a/docs/articles/expensify-classic/expense-and-report-features/Attendee-Tracking.md +++ b/docs/articles/expensify-classic/expense-and-report-features/Attendee-Tracking.md @@ -2,4 +2,4 @@ title: Attendee Tracking description: Attendee Tracking --- -## Resources Coming Soon! +## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/expense-and-report-features/Currency.md b/docs/articles/expensify-classic/expense-and-report-features/Currency.md index 611365aa5013..e5c9096fa610 100644 --- a/docs/articles/expensify-classic/expense-and-report-features/Currency.md +++ b/docs/articles/expensify-classic/expense-and-report-features/Currency.md @@ -2,4 +2,4 @@ title: Currency description: Currency --- -## Resources Coming Soon! +## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/expense-and-report-features/Expense-Rules.md b/docs/articles/expensify-classic/expense-and-report-features/Expense-Rules.md index 81c664497e14..304c93d1da6d 100644 --- a/docs/articles/expensify-classic/expense-and-report-features/Expense-Rules.md +++ b/docs/articles/expensify-classic/expense-and-report-features/Expense-Rules.md @@ -2,4 +2,4 @@ title: Expense Rules description: Expense Rules --- -## Resources Coming Soon! +## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/expense-and-report-features/Expense-Types.md b/docs/articles/expensify-classic/expense-and-report-features/Expense-Types.md index a75209e4dfb1..3f2e49952c4a 100644 --- a/docs/articles/expensify-classic/expense-and-report-features/Expense-Types.md +++ b/docs/articles/expensify-classic/expense-and-report-features/Expense-Types.md @@ -2,4 +2,4 @@ title: Expense Types description: Expense Types --- -## Resources Coming Soon! +## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/expense-and-report-features/Report-Comments.md b/docs/articles/expensify-classic/expense-and-report-features/Report-Comments.md index 3938c02bd333..b7ed120fb28b 100644 --- a/docs/articles/expensify-classic/expense-and-report-features/Report-Comments.md +++ b/docs/articles/expensify-classic/expense-and-report-features/Report-Comments.md @@ -2,4 +2,4 @@ title: Report Comments description: Report Comments --- -## Resources Coming Soon! +## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/expense-and-report-features/The-Expenses-Page.md b/docs/articles/expensify-classic/expense-and-report-features/The-Expenses-Page.md index f202587568e5..f30dde9efc3d 100644 --- a/docs/articles/expensify-classic/expense-and-report-features/The-Expenses-Page.md +++ b/docs/articles/expensify-classic/expense-and-report-features/The-Expenses-Page.md @@ -2,4 +2,4 @@ title: The Expenses Page description: The Expenses Page --- -## Resources Coming Soon! +## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/expense-and-report-features/The-Reports-Page.md b/docs/articles/expensify-classic/expense-and-report-features/The-Reports-Page.md index 37da613e750a..e72abfcad51a 100644 --- a/docs/articles/expensify-classic/expense-and-report-features/The-Reports-Page.md +++ b/docs/articles/expensify-classic/expense-and-report-features/The-Reports-Page.md @@ -2,4 +2,4 @@ title: The Reports Page description: The Reports Page --- -## Resources Coming Soon! +## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/expensify-card/Auto-Reconciliation.md b/docs/articles/expensify-classic/expensify-card/Auto-Reconciliation.md index e1d1a990b166..85202835a0e4 100644 --- a/docs/articles/expensify-classic/expensify-card/Auto-Reconciliation.md +++ b/docs/articles/expensify-classic/expensify-card/Auto-Reconciliation.md @@ -2,4 +2,4 @@ title: Auto-reconciliation description: Auto-reconciliation --- -## Resources Coming Soon! +## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/expensify-card/CPA-Card.md b/docs/articles/expensify-classic/expensify-card/CPA-Card.md index 9f4c47a6a402..dfc1e71192db 100644 --- a/docs/articles/expensify-classic/expensify-card/CPA-Card.md +++ b/docs/articles/expensify-classic/expensify-card/CPA-Card.md @@ -2,4 +2,4 @@ title: CPA Card description: CPA Card --- -## Resources Coming Soon! +## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/expensify-card/Card-Settings.md b/docs/articles/expensify-classic/expensify-card/Card-Settings.md index ff9a959d38aa..ab212354974a 100644 --- a/docs/articles/expensify-classic/expensify-card/Card-Settings.md +++ b/docs/articles/expensify-classic/expensify-card/Card-Settings.md @@ -2,4 +2,4 @@ title: Card Settings description: Card Settings --- -## Resources Coming Soon! +## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/expensify-card/Connect-To-Indirect-Integration.md b/docs/articles/expensify-classic/expensify-card/Connect-To-Indirect-Integration.md index 0e05269f6501..9888edd139ac 100644 --- a/docs/articles/expensify-classic/expensify-card/Connect-To-Indirect-Integration.md +++ b/docs/articles/expensify-classic/expensify-card/Connect-To-Indirect-Integration.md @@ -2,4 +2,4 @@ title: Connect to Indirect Integration description: Connect to Indirect Integration --- -## Resources Coming Soon! +## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/expensify-card/File-A-Dispute.md b/docs/articles/expensify-classic/expensify-card/File-A-Dispute.md index 296999410687..694bce3da059 100644 --- a/docs/articles/expensify-classic/expensify-card/File-A-Dispute.md +++ b/docs/articles/expensify-classic/expensify-card/File-A-Dispute.md @@ -2,4 +2,4 @@ title: File a Dispute description: File a Dispute --- -## Resources Coming Soon! +## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/expensify-card/Get-The-Card.md b/docs/articles/expensify-classic/expensify-card/Get-The-Card.md index 9c8e804f6363..e5233a3732a3 100644 --- a/docs/articles/expensify-classic/expensify-card/Get-The-Card.md +++ b/docs/articles/expensify-classic/expensify-card/Get-The-Card.md @@ -2,4 +2,4 @@ title: Get the Card description: Get the Card --- -## Resources Coming Soon! +## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/expensify-card/Statements.md b/docs/articles/expensify-classic/expensify-card/Statements.md index 602fa610dd0b..b48d303a1a9b 100644 --- a/docs/articles/expensify-classic/expensify-card/Statements.md +++ b/docs/articles/expensify-classic/expensify-card/Statements.md @@ -2,4 +2,4 @@ title: Statements description: Statements --- -## Resources Coming Soon! +## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/expensify-card/The-Reports-Page.md b/docs/articles/expensify-classic/expensify-card/The-Reports-Page.md deleted file mode 100644 index 37da613e750a..000000000000 --- a/docs/articles/expensify-classic/expensify-card/The-Reports-Page.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: The Reports Page -description: The Reports Page ---- -## Resources Coming Soon! diff --git a/docs/articles/expensify-classic/exports/Custom-Templates.md b/docs/articles/expensify-classic/exports/Custom-Templates.md index 5dcfe58b09f5..e01450a730cf 100644 --- a/docs/articles/expensify-classic/exports/Custom-Templates.md +++ b/docs/articles/expensify-classic/exports/Custom-Templates.md @@ -2,4 +2,4 @@ title: Custom Templates description: Custom Templates --- -## Resources Coming Soon! +## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/exports/Default-Export-Templates.md b/docs/articles/expensify-classic/exports/Default-Export-Templates.md index 4dcb624698af..7650cff38946 100644 --- a/docs/articles/expensify-classic/exports/Default-Export-Templates.md +++ b/docs/articles/expensify-classic/exports/Default-Export-Templates.md @@ -2,4 +2,4 @@ title: Default Export Templates description: Default Export Templates --- -## Resources Coming Soon! +## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/exports/The-Reports-Page.md b/docs/articles/expensify-classic/exports/The-Reports-Page.md deleted file mode 100644 index 37da613e750a..000000000000 --- a/docs/articles/expensify-classic/exports/The-Reports-Page.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: The Reports Page -description: The Reports Page ---- -## Resources Coming Soon! diff --git a/docs/articles/expensify-classic/get-paid-back/Mileage.md b/docs/articles/expensify-classic/get-paid-back/Mileage.md index 381bc28626f9..248e80e1c115 100644 --- a/docs/articles/expensify-classic/get-paid-back/Mileage.md +++ b/docs/articles/expensify-classic/get-paid-back/Mileage.md @@ -2,4 +2,4 @@ title: Mileage description: Mileage --- -## Resources Coming Soon! +## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/get-paid-back/Per-Diem.md b/docs/articles/expensify-classic/get-paid-back/Per-Diem.md index e5a57fc62bdf..780e5969c441 100644 --- a/docs/articles/expensify-classic/get-paid-back/Per-Diem.md +++ b/docs/articles/expensify-classic/get-paid-back/Per-Diem.md @@ -2,4 +2,4 @@ title: Per Diem description: Per Diem --- -## Resources Coming Soon! +## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/get-paid-back/Third-Party-Payments.md b/docs/articles/expensify-classic/get-paid-back/Third-Party-Payments.md index d472e54778e1..a8cddcdfdd42 100644 --- a/docs/articles/expensify-classic/get-paid-back/Third-Party-Payments.md +++ b/docs/articles/expensify-classic/get-paid-back/Third-Party-Payments.md @@ -2,4 +2,4 @@ title: Third Party Payments description: Third Party Payments --- -## Resources Coming Soon! +## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/get-paid-back/Trips.md b/docs/articles/expensify-classic/get-paid-back/Trips.md index 3499865c4ee9..7efba1875a90 100644 --- a/docs/articles/expensify-classic/get-paid-back/Trips.md +++ b/docs/articles/expensify-classic/get-paid-back/Trips.md @@ -2,4 +2,4 @@ title: Trips description: Trips --- -## Resources Coming Soon! +## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/get-paid-back/expenses/Apply-Tax.md b/docs/articles/expensify-classic/get-paid-back/expenses/Apply-Tax.md index 224b622cec3f..36e0a2194d24 100644 --- a/docs/articles/expensify-classic/get-paid-back/expenses/Apply-Tax.md +++ b/docs/articles/expensify-classic/get-paid-back/expenses/Apply-Tax.md @@ -2,4 +2,4 @@ title: Apply Tax description: Apply Tax --- -## Resources Coming Soon! +## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/get-paid-back/expenses/Create-Expenses.md b/docs/articles/expensify-classic/get-paid-back/expenses/Create-Expenses.md index 8f4d035e1fe7..e565e59dc754 100644 --- a/docs/articles/expensify-classic/get-paid-back/expenses/Create-Expenses.md +++ b/docs/articles/expensify-classic/get-paid-back/expenses/Create-Expenses.md @@ -1,5 +1,124 @@ --- -title: Create Expenses -description: Create Expenses +title: Create-Expenses.md +description: This is an article that shows you all the ways that you can create Expenses in Expensify! --- -## Resources Coming Soon! + + +# About +Whether you're using SmartScan for automatic expense creation, or manually creating, splitting, or duplicating expenses, you can rest assured your expenses will be correctly tracked in Expensify. + +# How-to Create Expenses +## Using SmartScan +Use the big green camera button within the Expensify mobile app to snap a photo of your physical receipt to have it SmartScanned. +For digital or emailed receipts, simply forward them to receipts@expensify.com and it will be SmartScanned and added to your Expensify account. + +There’s no need to keep the app open and most SmartScans are finished within the hour. If more details are needed, Concierge will reach out to you with a friendly message. +## Using the Mobile App +Simply tap the **+** icon in the top-right corner +Choose **Expense** and then select **Manually Create**. +If you don't have a receipt handy or want to add it later, fill in your expense details and click the **Save** button. +## Using the Expensify Website +Log into the Expensify website +Click on the **Expenses** page and find the **New Expense** dropdown. +Select your expense type, hit the **Save** button and you're all set. +You can then add details like the Merchant and Category, attach a receipt image, and even add a description. +# How to Split an Expense +Splitting an expense in Expensify allows you to break down a single expense into multiple expenses. Each split expense is treated as an individual expense which can be categorized and tagged separately. The same receipt image will be attached to all of the split expenses, allowing you to divide a single expense into smaller, more manageable expenses. +To split an expense on the mobile app: + +1. Open an expense. +2. At the bottom of the screen, tap **More Options**. +3. Then, use the **Split** button to divide the expense. + +To split an expense on the Expensify website: + +1. Click on the expense you want to split. +2. Click on the **Split** button. + - On the Expenses page, this button is at the top. + - Within an individual expense, you'll find it at the bottom. +3. This will automatically be split in two, but you can decide how many expenses you want to split it into by clicking on the **Add Split** button. + - Remember, the total of all pieces must add up to the original expense amount, and no piece can have a $0.00 amount (or you won't be able to save the changes). + +# How to Create Bulk Expenses + +If you have multiple saved receipt images or PDFs to upload, you can drag and drop them onto your Expenses page in batches of ten - this will start the SmartScan process for all of them. + +You can also create a number of future 'placeholder' expenses for your recurring expenses (such as recurring bills or subscriptions) which you don't have receipts for by clicking *New Expense > Create Multiple* to quickly add multiple expenses in batches of up to ten. + +# How to Edit Bulk Expenses +Editing expenses in bulk will allow you to apply the same coding across multiple expenses and is a web-only feature. To bulk edit expenses: +Go to the Expenses page. +To narrow down your selection, use the filters (e.g. "Merchant" and "Open") to find the specific expenses you want to edit. +Select all the expenses you want to edit. +Click on the **Edit Multiple** button at the top of the page. +# How to Edit Expenses on a Report +If you’d like to edit expenses within an Open report: + +1. Click on the Report containing all the expenses. +2. Click on **Details**. +3. Click on the Pencil icon. +3. Select the **Edit Multiple** button. + +If you've already submitted your report, you'll need to Retract it or have it Unapproved first before you can edit the expenses. + + +# FAQ +## Does Expensify account for duplicates? + +Yes, Expensify will account for duplicates. Expensify works behind the scenes to identify duplicate expenses before they are submitted, warning employees when they exist. If a duplicate expense is submitted, the same warning will be shown to the approver responsible for reviewing the report. + +If two expenses are SmartScanned on the same day for the same amount, they will be flagged as duplicates unless: +The expenses were split from a single expense, +The expenses were imported from a credit card, or +Matching email receipts sent to receipts@expensify.com were received with different timestamps. +## How do I resolve a duplicate expense? + +If Concierge has let you know it's flagged a receipt as a duplicate, scanning the receipt again will trigger the same duplicate flagging.Users have the ability to resolve duplicates by either deleting the duplicated transactions, merging them, or ignoring them (if they are legitimately separate expenses of the same date and amount). + +## How do I recover a duplicate or undelete an expense? + +To recover a duplicate or undelete an expense: +Log into your Expensify account on the website and navigate to the Expenses page +Use the filters to search for deleted expenses by selecting the "Deleted" filter +Select the checkbox next to the expenses you want to restore +Click the **Undelete** button and you're all set. You’ll find the expense on your Expenses page again. + +# Deep Dive + +## What are the different Expense statuses? + +There are a number of different expense statuses in Expensify: +1. **Unreported**: Unreported expenses are not yet part of a report (and therefore unsubmitted) and are not viewable by anyone but the expense creator/owner. +2. **Open**: Open expenses are on a report that's still in progress, and are unsubmitted. Your Policy Admin will be able to view them, making it a collaborative step toward reimbursement. +3. **Processing**: Processing expenses are submitted, but waiting for approval. +4. **Approved**: If it's a non-reimbursable expense, the workflow is complete at this point. If it's a reimbursable expense, you're one step closer to getting paid. +5. **Reimbursed**: Reimbursed expenses are fully settled. You can check the Report Comments to see when you'll get paid. +6. **Closed**: Sometimes an expense accidentally ends up on your Individual Policy, falling into the Closed status. You’ll need to reopen the report and change the Policy by clicking on the **Details** tab in order to resubmit your report. +## What are Violations? + +Violations represent errors or discrepancies that Expensify has picked up and need to be corrected before a report can be successfully submitted. The one exception is when an expense comment is added, it will override the violation - as the user is providing a valid reason for submission. + +To enable or configure violations according to your policy, go to **Settings > Policies > _Policy Name_ > Expenses > Expense Violations**. Keep in mind that Expensify includes certain system mandatory violations that can't be disabled, even if your policy has violations turned off. + +You can spot violations by the exclamation marks (!) attached to expenses. Hovering over the symbol will provide a brief description and you can find more detailed information below the list of expenses. The two types of violations are: +**Red**: These indicate violations directly tied to your report's Policy settings. They are clear rule violations that must be addressed before submission. +**Yellow**: Concierge will highlight items that require attention but may not necessarily need corrective action. For example, if a receipt was SmartScanned and then the amount was modified, we’ll bring it to your attention so that it can be manually reviewed. +## How to Track Attendees + +Attendee tracking makes it easy to track shared expenses and maintain transparency in your group spending. + +Internal attendees are considered users within your policies or domain. To add internal attendees on mobile or web: +1. Click or tap the **Attendee** field within your expense. +2. Select the internal attendees you'd like to add from the list of searchable users. +3. You can continue adding more attendees or save the Expense. + +External attendees are considered users outside your group policy or domain. To add external attendees: +1. Click or tap the **Attendee** field within your expense. +2. Type in the individual's name or email address. +3. Tap **Add** to include the attendee. +You can continue adding more attendees or save the Expense. +To remove an attendee from an expense: +Open the expense. +Click or tap the **Attendees** field to display the list of attendees. +From the list, de-select the attendees you'd like to remove from the expense. + diff --git a/docs/articles/expensify-classic/get-paid-back/expenses/Merge-Expenses.md b/docs/articles/expensify-classic/get-paid-back/expenses/Merge-Expenses.md index c628244c9b2e..bfbc0773768c 100644 --- a/docs/articles/expensify-classic/get-paid-back/expenses/Merge-Expenses.md +++ b/docs/articles/expensify-classic/get-paid-back/expenses/Merge-Expenses.md @@ -1,5 +1,64 @@ --- title: Merge Expenses -description: Merge Expenses +description: This article shows you all the ways that you can merge your expenses in Expensify! --- -## Resources Coming Soon! + + +# About +The merge expense function helps combine two separate expenses into one. This is useful when the same expense has been accidentally entered more than once, or if you have a connected credit card and an imported expense didn’t automatically merge with a manual entry. + +# How-to merge expenses +It’s important to note that merging expenses doesn't add the two values together. Instead, merging them combines both expenses to create a single, consolidated expense. + +Keep in mind: +1. Merging expenses cannot be undone. +2. You can only merge two expenses at a time. +3. You can merge a cash expense with a credit card expense, or two cash expenses - but not two credit card expenses. +4. In order to merge, both expenses will need to be in an Open or Unreported state. + +# How to merge expenses on the web app +To merge two expenses from the Expenses page: +1. Sign into your Expensify account. +2. Navigate to the Expenses page on the left-hand navigation. +3. Click the checkboxes next to the two expenses you wish to merge. +4. Click **Merge**. +5. You'll be able to choose which aspect of each of the two expenses you would like to be used on the resulting expense, such as the receipt image, card, merchant, category, and more. + +To merge two expenses from the Reports page: +1. Sign into your Expensify account. +2. Navigate to the Reports page on the left-hand navigation. +3. Click the Report that contains the expenses that you wish to merge. +4. Click on the **Details** tab, then the Pencil icon. +5. Select the two expenses that you wish to merge. +6. You'll be able to choose which aspect of each of the two expenses you would like to be used on the resulting expense, such as the receipt image, card, merchant, category, and more. + +# How to merge expenses on the Expensify mobile app +On the mobile app, merging is prompted when you see the message _"Potential duplicate expense detected"_. Simply tap **Resolve Now** to take a closer look, then hit **Merge Expense**, and you're done! + +If the expenses exist on two different reports, you will be asked which report you'd like the newly created single expense to be reported onto. + +# FAQ + +## Can you merge expenses across different reports? + +You cannot merge expenses across different reports. Expenses will only merge if they are on the same report. If you have expenses across different reports that you wish to merge, you’ll need to move both expenses onto the same report (and ensure they are in the Open status) in order to merge them. + +## Can you merge expenses across different accounts? + +You cannot merge expenses across two separate accounts. You will need to choose one submitter and transfer the expense information to that user's account in order to merge the expense. +## Can you merge expenses with different currencies? + +Yes, you can merge expenses with different currencies. The conversion amount will be based on the daily exchange rate for the date of the transaction, as long as the converted rates are within +/- 5%. If the currencies are the same, then the amounts must be an exact match to merge. + +## Can Expensify automatically merge a cash expense with a credit card expense? + +Yes, Expensify can merge a cash expense with a credit card expense. A receipt will need to be SmartScanned via the app or forwarded to [receipts@expensify.com](mailto:receipts@expensify.com) in order to merge with a card expense. Note that the SmartScan must be fully completed and not stopped or edited, otherwise the two won’t merge. + +## It doesn’t look like my cash and card expenses merged properly. What are some troubleshooting tips? +First, check the expense types - you can only merge a SmartScanned receipt (which will initially show with a cash icon) with a card transaction imported from a bank or via CSV. + +If the card expense in your Expensify account is older than the receipt you're trying to merge it with, they won't merge, and if the receipt is dated more than 7 days prior to the card expense, then they also will not merge. + +If you have any expenses that are more than 90 days old from the date they were incurred (not the date they were imported to Expensify), Expensify will not automatically merge them. This safeguard helps prevent the merging of very old expenses that might not align with recent transactions or receipts. + +Lastly, transactions imported with the Expensify API (via the Expense Importer) will not automatically merge with SmartScanned transactions. diff --git a/docs/articles/expensify-classic/get-paid-back/expenses/Upload-Receipts.md b/docs/articles/expensify-classic/get-paid-back/expenses/Upload-Receipts.md index 2091b5f3e7f0..29380dab5a5b 100644 --- a/docs/articles/expensify-classic/get-paid-back/expenses/Upload-Receipts.md +++ b/docs/articles/expensify-classic/get-paid-back/expenses/Upload-Receipts.md @@ -1,5 +1,36 @@ --- -title: Upload Receipts -description: Upload Receipts +title: Upload-Receipts.md +description: This article shows you all the ways that you can upload your receipts to Expensify! --- -## Resources Coming Soon! + + +# About +Need to get paid? Check out this guide to see all the ways that you can upload your receipts to Expensify - whether it’s by SmartScanning them by forwarding via email or manually by taking a picture of a receipt, we’ll cover it here! + +# How-to Upload Receipts +## SmartScan +The easiest way to upload your receipts to Expensify is to SmartScan them with Expensify’s mobile app or forward a receipt from your email inbox! + +When you SmartScan a receipt, we’ll read the Merchant, Date and Amount of the transaction, create an expense, and add it to your Expensify account automatically. The best practice is to take a picture of the receipt at the time of purchase or forward it to your Expensify account from the point of sale system. If you have a credit card connected and you upload a receipt that matches a card expense, the SmartScanned receipt will automatically merge with the imported card expense instead. + +## Email Receipts +To SmartScan a receipt on your mobile app, tap the green camera button, point and shoot! You can also forward your digital receipts (or photos of receipts) to receipts@expensify.com from the email address associated with your Expensify account, and they’ll be SmartScanned. This may take a few minutes because Expensify aims to have the most accurate OCR. + +## Manually Upload +To upload receipts on the web, simply navigate to the Expenses page and click on **New Expense**. Select **Scan Receipt** and choose the file you would like to upload, or drag-and-drop your image directly into the Expenses page, and that will start the SmartScanning process! + +# FAQ +## How do you SmartScan multiple receipts? +You can utilize the Rapid Fire Mode to quickly SmartScan multiple receipts at once! + +To activate it, tap on the green camera button in the mobile app and then tap on the camera icon on the bottom right. When you see the little fire icon on the camera, Rapid Fire Mode has been activated - tap the camera icon again to disable Rapid Fire Mode. + +## How do you create an expense from an email address that is different from your Expensify login? +You can email a receipt from a different email address by adding it as a Secondary Login to your Expensify account - this ensures that any receipts sent from this email to receipts@expensify.com will be associated with your current Expensify account. + +Once that email address has been added as a Secondary Login, simply forward your receipt image or emails to receipts@expensify.com. + +## How do you crop or rotate a receipt image? +You can crop and rotate a receipt image on the web app, and you can only edit one expense at a time. + +Navigate to your Expenses page and locate the expense whose receipt image you'd like to edit, then click the expense to open the Edit screen. If there is an image file associated with the receipt, you will see the Rotate and Crop buttons. Alternatively, you can also navigate to your Reports page, click on a report, and locate the individual expense. diff --git a/docs/articles/expensify-classic/get-paid-back/reports/Create-A-Report.md b/docs/articles/expensify-classic/get-paid-back/reports/Create-A-Report.md index e6cc65290e73..ea808695e7cd 100644 --- a/docs/articles/expensify-classic/get-paid-back/reports/Create-A-Report.md +++ b/docs/articles/expensify-classic/get-paid-back/reports/Create-A-Report.md @@ -1,5 +1,166 @@ --- title: Create a Report -description: Create a Report +description: Learn how to create and edit reports in Expensify --- -## Resources Coming Soon! + + +# Overview + +This article covers all the basics of creating, editing, deleting and managing your reports. + +# How to create a report + +_Using the web app:_ + +To create a report on the Expensify website, click the New Report button on the **Reports** page. + +_Using the mobile app:_ + +Tap the ☰ icon. +Tap **Reports**. +Tap the **+** icon. +Choose your desired report type. + +# How to edit a report + +## Adding expenses to a report + +You can add expenses to the report by clicking **Add Expenses** at the top of the report. + +## Removing expenses from a report on the Expensify web app + +To remove expenses from the report on the web app, click the red ❌ next to the expense. + +## Removing expenses from a report on the Expensify mobile app + +To remove an expense on an Android device, hold the expense and tap **Delete**. + +To remove an expense on an iOS device, swipe the expense to the left and tap **Delete**. + +## Editing the report title + +To edit the report title, click the pencil icon next to the name. To save your changes, tap the enter key on your keyboard. + +**Note:** You may be unable to edit your reports' titles based on the settings. + +## Bulk-editing expenses on a report + +Click Details in the top-right of the report on the web app, then click the pencil icon to bring up the editing modal. You can click the pencil icon to the left of an expense to edit it, or you can edit multiple expenses at once by ticking the checkbox of the expenses you’d like to bulk-edit and then clicking **Edit Multiple** at the top of the modal. + +## Commenting on the report + +You can comment on the report by adding your comment to the **Report Comments** section at the bottom. Expensify will also log report actions here. + +## Attachments + +If you’d like to attach a photo or document to the report, follow the instructions below to add the attachment to your report comment section. + +_Using the web app:_ + +1. Click the **Paperclip** icon in the comment box of the **Report Comments** section. +2. Select the file to attach. +3. Check the preview of the attachment and click Upload. + +_Using the mobile app:_ + +1. Tap into the report. +2. Scroll to the bottom of the report and tap the paper clip icon to attach a file. + +**Note:** Report comments support jpeg, jpg, png, gif, csv, and pdf files. + +## Changing the report's workspace + +To change the report's workspace, click **Details** in the top-right of the report on the web app, then select the correct workspace from the **Workspace** drop-down. + +## Changing the report type (Expense Report/Invoice) + +To change the report type, click **Details** in the top-right of the report on the web app, then select the correct report type from the **Type** drop-down. + +## Changing the layout of the report + +There are three ways you can change the report layout under the Details section of the report. To do this, select the desired layout from the relevant drop-down menu: + + - **View** - Choose between a Basic or Detailed report view. + - **Group By** - Choose to group expenses on the report based on their Category or Tag. + - **Split By** - Split out the expenses based on their Reimbursable or Billable status. + +# How to submit a report + +1. Click **Submit** in the top-left of the report (or **Submit Report** at the top in the mobile app). +2. Verify the approver and click **Submit** again. + +# How to retract your report (Undo Submit) + +As long as the report is still in a Processing state, you can retract this submission to put the report back to Draft status to make corrections and re-submit. + +To retract a **Processing** report on the web app, click the Undo Submit button at the upper left-hand corner of the report. + +To complete this from the mobile app, simply open the report from within your app and click the **Retract** button at the top of the report. + +# How to share a report + +Click Details in the top-right of the report on the web app to bring up the sharing settings. The following options are available: + + - Click the **Printer** icon to print the report. + - Click the **Download** icon to download a PDF of the report + - Click the **Share** icon to share the report via email or SMS. + +# How to close a report + +You can close your report if you don't need it approved by your employer. + +_To close a report on the Expensify website:_ + +1. Navigate to the report in question. +2. Click **Mark as Closed** at the top of the report. +3. You can re-open a report once it’s closed by clicking **Undo Close** at the top of the report. + +# How to delete a report + +_Deleting a report on the web app:_ + +Click Details in the top-right of the report on the web app, then click the Trash icon to delete the report. Any expenses on the report will move to an Unreported state. + +_Deleting a report on the mobile app:_ + +To delete a Draft report on an Android, press and hold the report name and tap **Delete**. + +To delete a Draft report on an iOS device, go to the **Reports** screen, swipe the report to the left, and tap **Delete**. + +_Deleting a report in the Processing, Approved, Reimbursed or Closed state:_ + +If you want to delete a Processing or Closed report, please follow the How to undo your report submission instructions in this article to move the report back into an Draft status, then follow the steps above. + +If you want to delete an Approved or Reimbursed report, please speak to your Company Admin as this may not be possible. + +# How to move expenses between reports + +Navigate to your Expenses page. +Tick the checkbox next to each expense you'd like to move. +Click the Add To Report button in the top right corner. +Select your desired report from the drop-down. + +# How to use Guided Review to clean up your report + +Open your report on the web app and click Review at the top. The system will walk you through each violation on the report. +As you go through each violation, click View to look at the expense in more detail or resolve any violations. +Click Next to move on to the next item. +Click Finish to complete the review process when you’re done. + +# FAQ + +## Is there a difference between Expense Reports, Bills, and Invoices? + +**Expense Reports** are submitted by an employee to their employer. They contain either personally incurred expenses that the employee should be reimbursed for, or non-reimbursable expenses (such as company card expenses) incurred by the employee that require tracking for accounting purposes. + +**Invoices** are reports that a business or contractor will send to another business to charge them for goods or services the business received. Each invoice will have a matching **Bill** owned by the recipient so they may use it to pay the invoice sender. + +## Which report type should I use? + +If you bought something on a company card or need to be reimbursed by your employer, you’ll need an **Expense Report**. + +If someone external to the business sends you an invoice for their services, you’ll want a **Bill** (or even better - use our Bill Pay process) + +## When should I submit my report? + +Your Company Admin can answer this one, and they may have configured the workspace’s [Scheduled Submit] setting to enforce a regular cadence for you. If not, you can still set this up under your [Individual workspace]. diff --git a/docs/articles/expensify-classic/get-paid-back/reports/Reimbursements.md b/docs/articles/expensify-classic/get-paid-back/reports/Reimbursements.md index 91c4459d2ebd..a31c0a582fd7 100644 --- a/docs/articles/expensify-classic/get-paid-back/reports/Reimbursements.md +++ b/docs/articles/expensify-classic/get-paid-back/reports/Reimbursements.md @@ -1,5 +1,42 @@ ---- -title: Reimbursements -description: Reimbursements ---- -## Resources Coming Soon! +# Overview + +If you want to know more about how and when you’ll be reimbursed through Expensify, we’ve answered your questions below. + +# How to Get Reimbursed + +To get paid back after submitting a report for reimbursement, you’ll want to be sure to connect your bank account. You can do that under **Settings** > **Account** > **Payments** > **Add a Deposit Account**. Once your employer has approved your report, the reimbursement will be paid into the account you added. + +# Deep Dive + +## Reimbursement Timing + +### US Bank Accounts + +If your company uses Expensify's ACH reimbursement we'll first check to see if the report is eligible for Rapid Reimbursement (next business day). For a report to be eligible for Rapid Reimbursement it must fall under two limits: + + - $100 per deposit bank account per day or less for the individuals being reimbursed or businesses receiving payments for bills. + - Less than $10,000 being disbursed in a 24-hour time period from the verified bank account being used to pay the reimbursement. + +If the request passes both checks, then you can expect to see funds deposited into your bank account on the next business day. + +If either limit has been reached, then you can expect to see funds deposited within your bank account within the typical ACH timeframe of 3-5 business days. + +### International Bank Accounts + +If receiving reimbursement to an international deposit account via Global Reimbursement, you should expect to see funds deposited in your bank account within 4 business days. + +## Bank Processing Timeframes + +Banks only process transactions and ACH activity on weekdays that are not bank holidays. These are considered business days. Additionally, the business day on which a transaction will be processed depends upon whether or not a request is created before or after the cutoff time, which is typically 3 pm PST. +For example, if your reimbursement is initiated at 4 pm on Wednesday, this is past the bank's cutoff time, and it will not begin processing until the next business day. +If that same reimbursement starts processing on Thursday, and it's estimated to take 3-5 business days, this will cover a weekend, and both days are not considered business days. So, assuming there are no bank holidays added into this mix, here is how that reimbursement timeline would play out: + +**Wednesday**: Reimbursement initiated after 3 pm PST; will be processed the next business day by your company’s bank. +**Thursday**: Your company's bank will begin processing the withdrawal request +**Friday**: Business day 1 +**Saturday**: Weekend +**Sunday**: Weekend +**Monday**: Business day 2 +**Tuesday**: Business day 3 +**Wednesday**: Business day 4 +**Thursday**: Business day 5 diff --git a/docs/articles/expensify-classic/getting-started/Best-Practices.md b/docs/articles/expensify-classic/getting-started/Best-Practices.md index 16b284ae60df..b02ea9d68fe6 100644 --- a/docs/articles/expensify-classic/getting-started/Best-Practices.md +++ b/docs/articles/expensify-classic/getting-started/Best-Practices.md @@ -2,4 +2,4 @@ title: Best Practices description: Best Practices --- -## Resources Coming Soon! +## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/getting-started/Employees.md b/docs/articles/expensify-classic/getting-started/Employees.md index f139c40be926..6d3c2dc705e1 100644 --- a/docs/articles/expensify-classic/getting-started/Employees.md +++ b/docs/articles/expensify-classic/getting-started/Employees.md @@ -2,4 +2,4 @@ title: Employees description: Employees --- -## Resources Coming Soon! +## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/getting-started/Individual-Users.md b/docs/articles/expensify-classic/getting-started/Individual-Users.md index 2e152ea515d7..de7a527df010 100644 --- a/docs/articles/expensify-classic/getting-started/Individual-Users.md +++ b/docs/articles/expensify-classic/getting-started/Individual-Users.md @@ -2,4 +2,4 @@ title: Individual Users description: Individual Users --- -## Resources Coming Soon! +## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/getting-started/Invite-Employees.md b/docs/articles/expensify-classic/getting-started/Invite-Employees.md index 5cdb8eb086b0..73dc7b8274f0 100644 --- a/docs/articles/expensify-classic/getting-started/Invite-Employees.md +++ b/docs/articles/expensify-classic/getting-started/Invite-Employees.md @@ -2,4 +2,4 @@ title: Invite Employees description: Invite Employees --- -## Resources Coming Soon! +## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/getting-started/Using-The-App.md b/docs/articles/expensify-classic/getting-started/Mobile-App.md similarity index 68% rename from docs/articles/expensify-classic/getting-started/Using-The-App.md rename to docs/articles/expensify-classic/getting-started/Mobile-App.md index 37767ea9d78d..7fa57abbdf61 100644 --- a/docs/articles/expensify-classic/getting-started/Using-The-App.md +++ b/docs/articles/expensify-classic/getting-started/Mobile-App.md @@ -2,4 +2,4 @@ title: Using the App description: Using the App --- -## Resources Coming Soon! +## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/getting-started/Plan-Types.md b/docs/articles/expensify-classic/getting-started/Plan-Types.md index 7bb725a1aa35..f0323947ee12 100644 --- a/docs/articles/expensify-classic/getting-started/Plan-Types.md +++ b/docs/articles/expensify-classic/getting-started/Plan-Types.md @@ -2,4 +2,4 @@ title: Plan-Types description: Plan-Types --- -## Resources Coming Soon! +## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/getting-started/Policy-Admins.md b/docs/articles/expensify-classic/getting-started/Policy-Admins.md index 91d56b0c4f71..484350f101a5 100644 --- a/docs/articles/expensify-classic/getting-started/Policy-Admins.md +++ b/docs/articles/expensify-classic/getting-started/Policy-Admins.md @@ -2,4 +2,4 @@ title: Policy Admins description: Policy Admins --- -## Resources Coming Soon! +## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/getting-started/Security.md b/docs/articles/expensify-classic/getting-started/Security.md index 41451e2ba958..5a0036e3e161 100644 --- a/docs/articles/expensify-classic/getting-started/Security.md +++ b/docs/articles/expensify-classic/getting-started/Security.md @@ -2,4 +2,4 @@ title: Security description: Security --- -## Resources Coming Soon! +## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/getting-started/Support/Your-Expensify-Account-Manager.md b/docs/articles/expensify-classic/getting-started/support/Your-Expensify-Account-Manager.md similarity index 100% rename from docs/articles/expensify-classic/getting-started/Support/Your-Expensify-Account-Manager.md rename to docs/articles/expensify-classic/getting-started/support/Your-Expensify-Account-Manager.md diff --git a/docs/articles/expensify-classic/getting-started/tips-and-tricks.md b/docs/articles/expensify-classic/getting-started/tips-and-tricks.md deleted file mode 100644 index d85c7f3a0cb9..000000000000 --- a/docs/articles/expensify-classic/getting-started/tips-and-tricks.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Tips and Tricks -description: Tips and Tricks ---- -## Resources Coming Soon! diff --git a/docs/articles/expensify-classic/integrations/hr-integrations/ADP.md b/docs/articles/expensify-classic/integrations/HR-integrations/ADP.md similarity index 66% rename from docs/articles/expensify-classic/integrations/hr-integrations/ADP.md rename to docs/articles/expensify-classic/integrations/HR-integrations/ADP.md index 4c91b7095a4a..3ee1c8656b4b 100644 --- a/docs/articles/expensify-classic/integrations/hr-integrations/ADP.md +++ b/docs/articles/expensify-classic/integrations/HR-integrations/ADP.md @@ -2,4 +2,4 @@ title: Coming Soon description: Coming Soon --- -## Resources Coming Soon! +## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/integrations/hr-integrations/Greenhouse.md b/docs/articles/expensify-classic/integrations/HR-integrations/Greenhouse.md similarity index 66% rename from docs/articles/expensify-classic/integrations/hr-integrations/Greenhouse.md rename to docs/articles/expensify-classic/integrations/HR-integrations/Greenhouse.md index 4c91b7095a4a..3ee1c8656b4b 100644 --- a/docs/articles/expensify-classic/integrations/hr-integrations/Greenhouse.md +++ b/docs/articles/expensify-classic/integrations/HR-integrations/Greenhouse.md @@ -2,4 +2,4 @@ title: Coming Soon description: Coming Soon --- -## Resources Coming Soon! +## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/integrations/hr-integrations/Gusto.md b/docs/articles/expensify-classic/integrations/HR-integrations/Gusto.md similarity index 66% rename from docs/articles/expensify-classic/integrations/hr-integrations/Gusto.md rename to docs/articles/expensify-classic/integrations/HR-integrations/Gusto.md index 4c91b7095a4a..3ee1c8656b4b 100644 --- a/docs/articles/expensify-classic/integrations/hr-integrations/Gusto.md +++ b/docs/articles/expensify-classic/integrations/HR-integrations/Gusto.md @@ -2,4 +2,4 @@ title: Coming Soon description: Coming Soon --- -## Resources Coming Soon! +## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/integrations/hr-integrations/QuickBooks-Time.md b/docs/articles/expensify-classic/integrations/HR-integrations/QuickBooks-Time.md similarity index 66% rename from docs/articles/expensify-classic/integrations/hr-integrations/QuickBooks-Time.md rename to docs/articles/expensify-classic/integrations/HR-integrations/QuickBooks-Time.md index 4c91b7095a4a..3ee1c8656b4b 100644 --- a/docs/articles/expensify-classic/integrations/hr-integrations/QuickBooks-Time.md +++ b/docs/articles/expensify-classic/integrations/HR-integrations/QuickBooks-Time.md @@ -2,4 +2,4 @@ title: Coming Soon description: Coming Soon --- -## Resources Coming Soon! +## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/integrations/hr-integrations/Rippling.md b/docs/articles/expensify-classic/integrations/HR-integrations/Rippling.md similarity index 66% rename from docs/articles/expensify-classic/integrations/hr-integrations/Rippling.md rename to docs/articles/expensify-classic/integrations/HR-integrations/Rippling.md index 4c91b7095a4a..3ee1c8656b4b 100644 --- a/docs/articles/expensify-classic/integrations/hr-integrations/Rippling.md +++ b/docs/articles/expensify-classic/integrations/HR-integrations/Rippling.md @@ -2,4 +2,4 @@ title: Coming Soon description: Coming Soon --- -## Resources Coming Soon! +## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/integrations/hr-integrations/Workday.md b/docs/articles/expensify-classic/integrations/HR-integrations/Workday.md similarity index 66% rename from docs/articles/expensify-classic/integrations/hr-integrations/Workday.md rename to docs/articles/expensify-classic/integrations/HR-integrations/Workday.md index 4c91b7095a4a..3ee1c8656b4b 100644 --- a/docs/articles/expensify-classic/integrations/hr-integrations/Workday.md +++ b/docs/articles/expensify-classic/integrations/HR-integrations/Workday.md @@ -2,4 +2,4 @@ title: Coming Soon description: Coming Soon --- -## Resources Coming Soon! +## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/integrations/hr-integrations/Zenefits.md b/docs/articles/expensify-classic/integrations/HR-integrations/Zenefits.md similarity index 66% rename from docs/articles/expensify-classic/integrations/hr-integrations/Zenefits.md rename to docs/articles/expensify-classic/integrations/HR-integrations/Zenefits.md index 4c91b7095a4a..3ee1c8656b4b 100644 --- a/docs/articles/expensify-classic/integrations/hr-integrations/Zenefits.md +++ b/docs/articles/expensify-classic/integrations/HR-integrations/Zenefits.md @@ -2,4 +2,4 @@ title: Coming Soon description: Coming Soon --- -## Resources Coming Soon! +## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/integrations/accounting-integrations/Bill-dot-com.md b/docs/articles/expensify-classic/integrations/accounting-integrations/Bill-dot-com.md deleted file mode 100644 index 4c91b7095a4a..000000000000 --- a/docs/articles/expensify-classic/integrations/accounting-integrations/Bill-dot-com.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Coming Soon -description: Coming Soon ---- -## Resources Coming Soon! diff --git a/docs/articles/expensify-classic/integrations/accounting-integrations/FinancalForce.md b/docs/articles/expensify-classic/integrations/accounting-integrations/FinancalForce.md index 4c91b7095a4a..3ee1c8656b4b 100644 --- a/docs/articles/expensify-classic/integrations/accounting-integrations/FinancalForce.md +++ b/docs/articles/expensify-classic/integrations/accounting-integrations/FinancalForce.md @@ -2,4 +2,4 @@ title: Coming Soon description: Coming Soon --- -## Resources Coming Soon! +## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/integrations/accounting-integrations/NetSuite.md b/docs/articles/expensify-classic/integrations/accounting-integrations/NetSuite.md index 4c91b7095a4a..3ee1c8656b4b 100644 --- a/docs/articles/expensify-classic/integrations/accounting-integrations/NetSuite.md +++ b/docs/articles/expensify-classic/integrations/accounting-integrations/NetSuite.md @@ -2,4 +2,4 @@ title: Coming Soon description: Coming Soon --- -## Resources Coming Soon! +## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/integrations/accounting-integrations/QuickBooks-Desktop.md b/docs/articles/expensify-classic/integrations/accounting-integrations/QuickBooks-Desktop.md index 4c91b7095a4a..3ee1c8656b4b 100644 --- a/docs/articles/expensify-classic/integrations/accounting-integrations/QuickBooks-Desktop.md +++ b/docs/articles/expensify-classic/integrations/accounting-integrations/QuickBooks-Desktop.md @@ -2,4 +2,4 @@ title: Coming Soon description: Coming Soon --- -## Resources Coming Soon! +## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/integrations/accounting-integrations/QuickBooks-Online.md b/docs/articles/expensify-classic/integrations/accounting-integrations/QuickBooks-Online.md index 4c91b7095a4a..3ee1c8656b4b 100644 --- a/docs/articles/expensify-classic/integrations/accounting-integrations/QuickBooks-Online.md +++ b/docs/articles/expensify-classic/integrations/accounting-integrations/QuickBooks-Online.md @@ -2,4 +2,4 @@ title: Coming Soon description: Coming Soon --- -## Resources Coming Soon! +## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/integrations/accounting-integrations/Sage-Intacct.md b/docs/articles/expensify-classic/integrations/accounting-integrations/Sage-Intacct.md index 4c91b7095a4a..3ee1c8656b4b 100644 --- a/docs/articles/expensify-classic/integrations/accounting-integrations/Sage-Intacct.md +++ b/docs/articles/expensify-classic/integrations/accounting-integrations/Sage-Intacct.md @@ -2,4 +2,4 @@ title: Coming Soon description: Coming Soon --- -## Resources Coming Soon! +## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/integrations/accounting-integrations/Xero.md b/docs/articles/expensify-classic/integrations/accounting-integrations/Xero.md index 4c91b7095a4a..3ee1c8656b4b 100644 --- a/docs/articles/expensify-classic/integrations/accounting-integrations/Xero.md +++ b/docs/articles/expensify-classic/integrations/accounting-integrations/Xero.md @@ -2,4 +2,4 @@ title: Coming Soon description: Coming Soon --- -## Resources Coming Soon! +## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/integrations/other-integrations/Google-Apps-SSO.md b/docs/articles/expensify-classic/integrations/other-integrations/Google-Apps-SSO.md index 4c91b7095a4a..3ee1c8656b4b 100644 --- a/docs/articles/expensify-classic/integrations/other-integrations/Google-Apps-SSO.md +++ b/docs/articles/expensify-classic/integrations/other-integrations/Google-Apps-SSO.md @@ -2,4 +2,4 @@ title: Coming Soon description: Coming Soon --- -## Resources Coming Soon! +## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/integrations/travel-integrations/Bolt.md b/docs/articles/expensify-classic/integrations/travel-integrations/Bolt.md index 4c91b7095a4a..3ee1c8656b4b 100644 --- a/docs/articles/expensify-classic/integrations/travel-integrations/Bolt.md +++ b/docs/articles/expensify-classic/integrations/travel-integrations/Bolt.md @@ -2,4 +2,4 @@ title: Coming Soon description: Coming Soon --- -## Resources Coming Soon! +## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/integrations/travel-integrations/Egencia.md b/docs/articles/expensify-classic/integrations/travel-integrations/Egencia.md index 4c91b7095a4a..3ee1c8656b4b 100644 --- a/docs/articles/expensify-classic/integrations/travel-integrations/Egencia.md +++ b/docs/articles/expensify-classic/integrations/travel-integrations/Egencia.md @@ -2,4 +2,4 @@ title: Coming Soon description: Coming Soon --- -## Resources Coming Soon! +## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/integrations/travel-integrations/Global-VaTax.md b/docs/articles/expensify-classic/integrations/travel-integrations/Global-VaTax.md index 4c91b7095a4a..3ee1c8656b4b 100644 --- a/docs/articles/expensify-classic/integrations/travel-integrations/Global-VaTax.md +++ b/docs/articles/expensify-classic/integrations/travel-integrations/Global-VaTax.md @@ -2,4 +2,4 @@ title: Coming Soon description: Coming Soon --- -## Resources Coming Soon! +## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/integrations/travel-integrations/Grab.md b/docs/articles/expensify-classic/integrations/travel-integrations/Grab.md index 4c91b7095a4a..3ee1c8656b4b 100644 --- a/docs/articles/expensify-classic/integrations/travel-integrations/Grab.md +++ b/docs/articles/expensify-classic/integrations/travel-integrations/Grab.md @@ -2,4 +2,4 @@ title: Coming Soon description: Coming Soon --- -## Resources Coming Soon! +## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/integrations/travel-integrations/Hotel-Tonight.md b/docs/articles/expensify-classic/integrations/travel-integrations/Hotel-Tonight.md index 4c91b7095a4a..3ee1c8656b4b 100644 --- a/docs/articles/expensify-classic/integrations/travel-integrations/Hotel-Tonight.md +++ b/docs/articles/expensify-classic/integrations/travel-integrations/Hotel-Tonight.md @@ -2,4 +2,4 @@ title: Coming Soon description: Coming Soon --- -## Resources Coming Soon! +## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/integrations/travel-integrations/Kayak.md b/docs/articles/expensify-classic/integrations/travel-integrations/Kayak.md index 4c91b7095a4a..3ee1c8656b4b 100644 --- a/docs/articles/expensify-classic/integrations/travel-integrations/Kayak.md +++ b/docs/articles/expensify-classic/integrations/travel-integrations/Kayak.md @@ -2,4 +2,4 @@ title: Coming Soon description: Coming Soon --- -## Resources Coming Soon! +## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/integrations/travel-integrations/Lyft.md b/docs/articles/expensify-classic/integrations/travel-integrations/Lyft.md index 4c91b7095a4a..3ee1c8656b4b 100644 --- a/docs/articles/expensify-classic/integrations/travel-integrations/Lyft.md +++ b/docs/articles/expensify-classic/integrations/travel-integrations/Lyft.md @@ -2,4 +2,4 @@ title: Coming Soon description: Coming Soon --- -## Resources Coming Soon! +## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/integrations/travel-integrations/TrainLine.md b/docs/articles/expensify-classic/integrations/travel-integrations/TrainLine.md index 4c91b7095a4a..3ee1c8656b4b 100644 --- a/docs/articles/expensify-classic/integrations/travel-integrations/TrainLine.md +++ b/docs/articles/expensify-classic/integrations/travel-integrations/TrainLine.md @@ -2,4 +2,4 @@ title: Coming Soon description: Coming Soon --- -## Resources Coming Soon! +## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/integrations/travel-integrations/TravelPerk.md b/docs/articles/expensify-classic/integrations/travel-integrations/TravelPerk.md index 4c91b7095a4a..3ee1c8656b4b 100644 --- a/docs/articles/expensify-classic/integrations/travel-integrations/TravelPerk.md +++ b/docs/articles/expensify-classic/integrations/travel-integrations/TravelPerk.md @@ -2,4 +2,4 @@ title: Coming Soon description: Coming Soon --- -## Resources Coming Soon! +## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/integrations/travel-integrations/Trip-Actions.md b/docs/articles/expensify-classic/integrations/travel-integrations/Trip-Actions.md index 4c91b7095a4a..3ee1c8656b4b 100644 --- a/docs/articles/expensify-classic/integrations/travel-integrations/Trip-Actions.md +++ b/docs/articles/expensify-classic/integrations/travel-integrations/Trip-Actions.md @@ -2,4 +2,4 @@ title: Coming Soon description: Coming Soon --- -## Resources Coming Soon! +## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/integrations/travel-integrations/TripCatcher.md b/docs/articles/expensify-classic/integrations/travel-integrations/TripCatcher.md index 4c91b7095a4a..3ee1c8656b4b 100644 --- a/docs/articles/expensify-classic/integrations/travel-integrations/TripCatcher.md +++ b/docs/articles/expensify-classic/integrations/travel-integrations/TripCatcher.md @@ -2,4 +2,4 @@ title: Coming Soon description: Coming Soon --- -## Resources Coming Soon! +## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/integrations/travel-integrations/Uber.md b/docs/articles/expensify-classic/integrations/travel-integrations/Uber.md index 4c91b7095a4a..3ee1c8656b4b 100644 --- a/docs/articles/expensify-classic/integrations/travel-integrations/Uber.md +++ b/docs/articles/expensify-classic/integrations/travel-integrations/Uber.md @@ -2,4 +2,4 @@ title: Coming Soon description: Coming Soon --- -## Resources Coming Soon! +## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/manage-employees-and-report-approvals/Adding-Users.md b/docs/articles/expensify-classic/manage-employees-and-report-approvals/Adding-Users.md index 4c91b7095a4a..3ee1c8656b4b 100644 --- a/docs/articles/expensify-classic/manage-employees-and-report-approvals/Adding-Users.md +++ b/docs/articles/expensify-classic/manage-employees-and-report-approvals/Adding-Users.md @@ -2,4 +2,4 @@ title: Coming Soon description: Coming Soon --- -## Resources 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 4c91b7095a4a..3ee1c8656b4b 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 @@ -2,4 +2,4 @@ title: Coming Soon description: Coming Soon --- -## Resources Coming Soon! +## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/manage-employees-and-report-approvals/Approving-Reports.md b/docs/articles/expensify-classic/manage-employees-and-report-approvals/Approving-Reports.md index 4c91b7095a4a..3ee1c8656b4b 100644 --- a/docs/articles/expensify-classic/manage-employees-and-report-approvals/Approving-Reports.md +++ b/docs/articles/expensify-classic/manage-employees-and-report-approvals/Approving-Reports.md @@ -2,4 +2,4 @@ title: Coming Soon description: Coming Soon --- -## Resources Coming Soon! +## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/manage-employees-and-report-approvals/User-Roles.md b/docs/articles/expensify-classic/manage-employees-and-report-approvals/User-Roles.md index 4c91b7095a4a..a65dc378a793 100644 --- a/docs/articles/expensify-classic/manage-employees-and-report-approvals/User-Roles.md +++ b/docs/articles/expensify-classic/manage-employees-and-report-approvals/User-Roles.md @@ -1,5 +1,63 @@ --- -title: Coming Soon -description: Coming Soon +title: User Roles +description: Each member has a role that defines what they can see and do in the workspace. --- -## Resources Coming Soon! + +# Overview + +This guide is for those who are part of a **Group Workspace**. + +Each member has a role that defines what they can see and do in the workspace. Most members will have the role of "Employee." + +# How to Manage User Roles + +To find and edit the roles of group workspace members, go to **Settings > Workspaces > Group > [Your Specific Workspace Name] > Members > Workspace Members** + +Here you'll see the list of members in your group workspace. To change their roles, click **Settings** next to the member’s name and choose the role that the member needs. + +Next, let’s go over the various user roles that are available on a group workspace. + +## The Employee Role + +- **What can they do:** Employees can only see their own expense reports or reports that have been submitted to or shared with them. They can't change settings or invite new users. +- **Who is it for:** Regular employees who only need to manage their own expenses, or managers who are reviewing expense reports for a few users but don’t need global visibility. +- **Approvers:** Members who approve expenses can either be Employees, Admins, or Workspace Auditors, depending on how much control they need. +- **Billable:** Employees are billable actors if they take actions on a report on your Group Workspace (including **SmartScanning** a receipt). + +## Workspace Admin Role + +- **What can they do:** Admins have full control. They can change settings, invite members, and view all reports. They can also process reimbursements if they have access to the company’s account. +- **Billing Owners:** Billing owners are Admins by default. **Workspace Admins** are assigned by the owner or another admin. +- **Billable:** Yes, if they perform actions like changing settings or inviting users. Just viewing reports is not billable. + +## Workspace Auditor Role + +- **What can they do:** Workspace Auditors can see all reports, make comments, and export them. They can also mark reports as reimbursed if they're the final approver. +- **Who is it for:** Accountants, bookkeepers, and internal or external audit agents who need to view but not edit workspace settings. +- **Billable:** Yes, if they perform any actions like commenting or exporting a report. Viewing alone doesn't incur a charge. + +## Technical Contact + +- **What can they do:** In case of connection issues, alerts go to the billing owner by default. You can set a technical contact if you want alerts to go to an IT administrator instead. +- **How to set one:** Go to **Settings > Workspaces > Group > [Workspace Name] > Connections > Technical Contact**. +- **Billable:** The technical contact doesn’t need to be a group workspace member and so is not counted towards your billable activity. + +Note: running expense analytics from **Insights** follows the same rules. All the reports and data graphs you generate will be created based on the expense data you have access to. + +# Deep Dive + +## Expense Data Visibility + +The amount of expense data you can see depends on your role within any group workspaces you're part of: + +- **Employees:** Whether you're on a free or paid plan, if you're not approving expenses, you'll only see your own expenses. +- **Approvers:** If you approve expenses for your team and also submit your own, you can view both individual and team-wide expenses and analytics. +- **Admins:** Users with an admin role can see analytics and data for every expense report made by anyone on the workspace. + +If you need to see more data, here are some options: + +- **Become an Admin:** Check within your organization if you can be upgraded to an admin role in your group workspaces. +- **Become a Copilot:** Ask to be added as a **Copilot** to an existing admin account, which will allow you some additional viewing privileges. +- **Become an Approver:** You could also be added as an **Approver** in an existing workflow to view more data. + + diff --git a/docs/articles/expensify-classic/manage-employees-and-report-approvals/Vacation-Delegate.md b/docs/articles/expensify-classic/manage-employees-and-report-approvals/Vacation-Delegate.md index e10e0fafb77d..e107734216f5 100644 --- a/docs/articles/expensify-classic/manage-employees-and-report-approvals/Vacation-Delegate.md +++ b/docs/articles/expensify-classic/manage-employees-and-report-approvals/Vacation-Delegate.md @@ -2,7 +2,7 @@ title: Coming Soon description: Coming Soon --- -## Resources Coming Soon! +## Resource Coming Soon! Kayak.md Lyft.md TrainLine.md TravelPerk.md Trip Actions.md TripCatcher.md Uber.md \ No newline at end of file diff --git a/docs/articles/expensify-classic/policy-and-domain-settings/Admins.md b/docs/articles/expensify-classic/policy-and-domain-settings/Admins.md index 8c1267068d6b..cea96cfe2057 100644 --- a/docs/articles/expensify-classic/policy-and-domain-settings/Admins.md +++ b/docs/articles/expensify-classic/policy-and-domain-settings/Admins.md @@ -2,4 +2,4 @@ title: Admins description: Admins --- -## Resources Coming Soon! +## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/policy-and-domain-settings/Categories.md b/docs/articles/expensify-classic/policy-and-domain-settings/Categories.md index 00ade2b9d04f..0db022f400d3 100644 --- a/docs/articles/expensify-classic/policy-and-domain-settings/Categories.md +++ b/docs/articles/expensify-classic/policy-and-domain-settings/Categories.md @@ -2,4 +2,4 @@ title: Categories description: Categories --- -## Resources Coming Soon! +## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/policy-and-domain-settings/Domain-Admins.md b/docs/articles/expensify-classic/policy-and-domain-settings/Domain-Admins.md index 4c91b7095a4a..3ee1c8656b4b 100644 --- a/docs/articles/expensify-classic/policy-and-domain-settings/Domain-Admins.md +++ b/docs/articles/expensify-classic/policy-and-domain-settings/Domain-Admins.md @@ -2,4 +2,4 @@ title: Coming Soon description: Coming Soon --- -## Resources Coming Soon! +## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/policy-and-domain-settings/Domain-Members.md b/docs/articles/expensify-classic/policy-and-domain-settings/Domain-Members.md index 4c91b7095a4a..3ee1c8656b4b 100644 --- a/docs/articles/expensify-classic/policy-and-domain-settings/Domain-Members.md +++ b/docs/articles/expensify-classic/policy-and-domain-settings/Domain-Members.md @@ -2,4 +2,4 @@ title: Coming Soon description: Coming Soon --- -## Resources Coming Soon! +## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/policy-and-domain-settings/Domains-Overview.md b/docs/articles/expensify-classic/policy-and-domain-settings/Domains-Overview.md index 4c91b7095a4a..3ee1c8656b4b 100644 --- a/docs/articles/expensify-classic/policy-and-domain-settings/Domains-Overview.md +++ b/docs/articles/expensify-classic/policy-and-domain-settings/Domains-Overview.md @@ -2,4 +2,4 @@ title: Coming Soon description: Coming Soon --- -## Resources Coming Soon! +## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/policy-and-domain-settings/Expenses.md b/docs/articles/expensify-classic/policy-and-domain-settings/Expenses.md index 4c91b7095a4a..3ee1c8656b4b 100644 --- a/docs/articles/expensify-classic/policy-and-domain-settings/Expenses.md +++ b/docs/articles/expensify-classic/policy-and-domain-settings/Expenses.md @@ -2,4 +2,4 @@ title: Coming Soon description: Coming Soon --- -## Resources Coming Soon! +## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/policy-and-domain-settings/Invoicing.md b/docs/articles/expensify-classic/policy-and-domain-settings/Invoicing.md index 4c91b7095a4a..3ee1c8656b4b 100644 --- a/docs/articles/expensify-classic/policy-and-domain-settings/Invoicing.md +++ b/docs/articles/expensify-classic/policy-and-domain-settings/Invoicing.md @@ -2,4 +2,4 @@ title: Coming Soon description: Coming Soon --- -## Resources Coming Soon! +## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/policy-and-domain-settings/Overview.md b/docs/articles/expensify-classic/policy-and-domain-settings/Overview.md index 4c91b7095a4a..3ee1c8656b4b 100644 --- a/docs/articles/expensify-classic/policy-and-domain-settings/Overview.md +++ b/docs/articles/expensify-classic/policy-and-domain-settings/Overview.md @@ -2,4 +2,4 @@ title: Coming Soon description: Coming Soon --- -## Resources Coming Soon! +## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/policy-and-domain-settings/Per-Diem.md b/docs/articles/expensify-classic/policy-and-domain-settings/Per-Diem.md index 4c91b7095a4a..3ee1c8656b4b 100644 --- a/docs/articles/expensify-classic/policy-and-domain-settings/Per-Diem.md +++ b/docs/articles/expensify-classic/policy-and-domain-settings/Per-Diem.md @@ -2,4 +2,4 @@ title: Coming Soon description: Coming Soon --- -## Resources Coming Soon! +## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/policy-and-domain-settings/Reimbursement.md b/docs/articles/expensify-classic/policy-and-domain-settings/Reimbursement.md index 4c91b7095a4a..3ee1c8656b4b 100644 --- a/docs/articles/expensify-classic/policy-and-domain-settings/Reimbursement.md +++ b/docs/articles/expensify-classic/policy-and-domain-settings/Reimbursement.md @@ -2,4 +2,4 @@ title: Coming Soon description: Coming Soon --- -## Resources Coming Soon! +## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/policy-and-domain-settings/Reports.md b/docs/articles/expensify-classic/policy-and-domain-settings/Reports.md index 4c91b7095a4a..3ee1c8656b4b 100644 --- a/docs/articles/expensify-classic/policy-and-domain-settings/Reports.md +++ b/docs/articles/expensify-classic/policy-and-domain-settings/Reports.md @@ -2,4 +2,4 @@ title: Coming Soon description: Coming Soon --- -## Resources Coming Soon! +## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/policy-and-domain-settings/SAML.md b/docs/articles/expensify-classic/policy-and-domain-settings/SAML.md index 4c91b7095a4a..3ee1c8656b4b 100644 --- a/docs/articles/expensify-classic/policy-and-domain-settings/SAML.md +++ b/docs/articles/expensify-classic/policy-and-domain-settings/SAML.md @@ -2,4 +2,4 @@ title: Coming Soon description: Coming Soon --- -## Resources Coming Soon! +## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/policy-and-domain-settings/Tags.md b/docs/articles/expensify-classic/policy-and-domain-settings/Tags.md index 4c91b7095a4a..3ee1c8656b4b 100644 --- a/docs/articles/expensify-classic/policy-and-domain-settings/Tags.md +++ b/docs/articles/expensify-classic/policy-and-domain-settings/Tags.md @@ -2,4 +2,4 @@ title: Coming Soon description: Coming Soon --- -## Resources Coming Soon! +## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/policy-and-domain-settings/Tax.md b/docs/articles/expensify-classic/policy-and-domain-settings/Tax.md deleted file mode 100644 index 4c91b7095a4a..000000000000 --- a/docs/articles/expensify-classic/policy-and-domain-settings/Tax.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Coming Soon -description: Coming Soon ---- -## Resources Coming Soon! diff --git a/docs/articles/expensify-classic/policy-and-domain-settings/Trips.md b/docs/articles/expensify-classic/policy-and-domain-settings/Trips.md index 4c91b7095a4a..3ee1c8656b4b 100644 --- a/docs/articles/expensify-classic/policy-and-domain-settings/Trips.md +++ b/docs/articles/expensify-classic/policy-and-domain-settings/Trips.md @@ -2,4 +2,4 @@ title: Coming Soon description: Coming Soon --- -## Resources Coming Soon! +## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/policy-and-domain-settings/tax-tracking.md b/docs/articles/expensify-classic/policy-and-domain-settings/tax-tracking.md new file mode 100644 index 000000000000..7b859c5101b1 --- /dev/null +++ b/docs/articles/expensify-classic/policy-and-domain-settings/tax-tracking.md @@ -0,0 +1,19 @@ +--- +title: Tax +description: How to track expense taxes +--- +# Overview +Expensify’s tax tracking feature allows you to: +- Add tax names, rates, and codes whether you’re connected to an accounting system or not. +- Enable/disable taxes you’d like to make available to users. +- Set a default tax for Workspace currency expenses and, optionally, another default tax (including exempt) for foreign currency expenses which - will automatically apply to all new expenses. + +# How to Enable Tax Tracking +Tax tracking can be enabled in the Tax section of the Workspace settings of any Workspace, whether group or individual. +## If Connected to an Accounting Integration +If your group Workspace is connected to Xero, QuickBooks Online, Sage Intacct, or NetSuite, make sure to first enable tax via the connection configuration page (Settings > Policies > Group > [Workspace Name] > Connections > Configure) and then sync the connection. Your tax rates will be imported from the accounting system and indicated by its logo. +## Not Connected to an Accounting Integration +If your Workspace is not connected to an accounting system, go to Settings > Policies > Group > [Workspace Name] > Tax to enable tax. + +# Tracking Tax by Expense Category +To set a different tax rate for a specific expense type in the Workspace currency, go to Settings > Workspaces > Group > [Workspace Name] > Categories page. Click "Edit Rules" next to the desired category and set the "Category default tax". This will be applied to new expenses, overriding the default Workspace currency tax rate. diff --git a/docs/articles/expensify-classic/send-payments/Pay-Bills.md b/docs/articles/expensify-classic/send-payments/Pay-Bills.md index e319196eb4bd..41c0146126ba 100644 --- a/docs/articles/expensify-classic/send-payments/Pay-Bills.md +++ b/docs/articles/expensify-classic/send-payments/Pay-Bills.md @@ -2,4 +2,4 @@ title: Pay Bills description: Pay Bills --- -## Resources Coming Soon! +## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/send-payments/Pay-Invoices.md b/docs/articles/expensify-classic/send-payments/Pay-Invoices.md index 0ea4d28a731a..e5e6799c268c 100644 --- a/docs/articles/expensify-classic/send-payments/Pay-Invoices.md +++ b/docs/articles/expensify-classic/send-payments/Pay-Invoices.md @@ -2,4 +2,4 @@ title: Pay Invoices description: Pay Invoices --- -## Resources Coming Soon! +## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/send-payments/Reimbursing-Reports.md b/docs/articles/expensify-classic/send-payments/Reimbursing-Reports.md index 6c3309310ba8..834d0b159931 100644 --- a/docs/articles/expensify-classic/send-payments/Reimbursing-Reports.md +++ b/docs/articles/expensify-classic/send-payments/Reimbursing-Reports.md @@ -2,4 +2,4 @@ title: Reimbursing Reports description: Reimbursing Reports --- -## Resources Coming Soon! +## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/send-payments/Third-Party-Payments.md b/docs/articles/expensify-classic/send-payments/Third-Party-Payments.md index 4b1166cc9c00..f61f26d91059 100644 --- a/docs/articles/expensify-classic/send-payments/Third-Party-Payments.md +++ b/docs/articles/expensify-classic/send-payments/Third-Party-Payments.md @@ -2,7 +2,7 @@ title: Third Party Payments description: Third Party Payments --- -## Resources Coming Soon! +## Resource Coming Soon! \ No newline at end of file diff --git a/docs/articles/new-expensify/get-paid-back/Request-Money.md b/docs/articles/new-expensify/get-paid-back/Request-Money.md index 55a3f3c8172e..dc6de6656cc9 100644 --- a/docs/articles/new-expensify/get-paid-back/Request-Money.md +++ b/docs/articles/new-expensify/get-paid-back/Request-Money.md @@ -2,4 +2,4 @@ title: Request Money description: Request Money --- -## Resources Coming Soon! +## Resource Coming Soon! diff --git a/docs/articles/new-expensify/integrations/accounting-integrations/QuickBooks-Online.md b/docs/articles/new-expensify/integrations/accounting-integrations/QuickBooks-Online.md index ed4d127d5c26..aa5f40ee4e5d 100644 --- a/docs/articles/new-expensify/integrations/accounting-integrations/QuickBooks-Online.md +++ b/docs/articles/new-expensify/integrations/accounting-integrations/QuickBooks-Online.md @@ -2,4 +2,4 @@ title: QuickBooks Online description: QuickBooks Online --- -## Resources Coming Soon! +## Resource Coming Soon! diff --git a/docs/assets/images/settings-new-dot.svg b/docs/assets/images/settings-new-dot.svg new file mode 100644 index 000000000000..13338fc72362 --- /dev/null +++ b/docs/assets/images/settings-new-dot.svg @@ -0,0 +1,88 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/assets/images/settings-old-dot.svg b/docs/assets/images/settings-old-dot.svg new file mode 100644 index 000000000000..89302b65c70d --- /dev/null +++ b/docs/assets/images/settings-old-dot.svg @@ -0,0 +1,187 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/assets/images/shield.svg b/docs/assets/images/shield.svg index 9066e116c94c..252da0321692 100644 --- a/docs/assets/images/shield.svg +++ b/docs/assets/images/shield.svg @@ -1,4 +1,16 @@ - - - + + + + + + + + + + + + + + + diff --git a/docs/expensify-classic/hubs/account-settings.html b/docs/expensify-classic/hubs/account-settings/index.html similarity index 100% rename from docs/expensify-classic/hubs/account-settings.html rename to docs/expensify-classic/hubs/account-settings/index.html diff --git a/docs/expensify-classic/hubs/bank-accounts-and-credit-cards/business-bank-accounts.html b/docs/expensify-classic/hubs/bank-accounts-and-credit-cards/business-bank-accounts.html new file mode 100644 index 000000000000..86641ee60b7d --- /dev/null +++ b/docs/expensify-classic/hubs/bank-accounts-and-credit-cards/business-bank-accounts.html @@ -0,0 +1,5 @@ +--- +layout: default +--- + +{% include section.html %} diff --git a/docs/expensify-classic/hubs/bank-accounts-and-credit-cards/company-cards.html b/docs/expensify-classic/hubs/bank-accounts-and-credit-cards/company-cards.html new file mode 100644 index 000000000000..86641ee60b7d --- /dev/null +++ b/docs/expensify-classic/hubs/bank-accounts-and-credit-cards/company-cards.html @@ -0,0 +1,5 @@ +--- +layout: default +--- + +{% include section.html %} diff --git a/docs/expensify-classic/hubs/bank-accounts-and-credit-cards/deposit-accounts.html b/docs/expensify-classic/hubs/bank-accounts-and-credit-cards/deposit-accounts.html new file mode 100644 index 000000000000..86641ee60b7d --- /dev/null +++ b/docs/expensify-classic/hubs/bank-accounts-and-credit-cards/deposit-accounts.html @@ -0,0 +1,5 @@ +--- +layout: default +--- + +{% include section.html %} diff --git a/docs/expensify-classic/hubs/bank-accounts-and-credit-cards.html b/docs/expensify-classic/hubs/bank-accounts-and-credit-cards/index.html similarity index 100% rename from docs/expensify-classic/hubs/bank-accounts-and-credit-cards.html rename to docs/expensify-classic/hubs/bank-accounts-and-credit-cards/index.html diff --git a/docs/expensify-classic/hubs/billing-and-subscriptions.html b/docs/expensify-classic/hubs/billing-and-subscriptions/index.html similarity index 100% rename from docs/expensify-classic/hubs/billing-and-subscriptions.html rename to docs/expensify-classic/hubs/billing-and-subscriptions/index.html diff --git a/docs/expensify-classic/hubs/expense-and-report-features.html b/docs/expensify-classic/hubs/expense-and-report-features/index.html similarity index 100% rename from docs/expensify-classic/hubs/expense-and-report-features.html rename to docs/expensify-classic/hubs/expense-and-report-features/index.html diff --git a/docs/expensify-classic/hubs/expensify-card.html b/docs/expensify-classic/hubs/expensify-card/index.html similarity index 100% rename from docs/expensify-classic/hubs/expensify-card.html rename to docs/expensify-classic/hubs/expensify-card/index.html diff --git a/docs/expensify-classic/hubs/exports.html b/docs/expensify-classic/hubs/exports/index.html similarity index 100% rename from docs/expensify-classic/hubs/exports.html rename to docs/expensify-classic/hubs/exports/index.html diff --git a/docs/expensify-classic/hubs/get-paid-back/expenses.html b/docs/expensify-classic/hubs/get-paid-back/expenses.html new file mode 100644 index 000000000000..86641ee60b7d --- /dev/null +++ b/docs/expensify-classic/hubs/get-paid-back/expenses.html @@ -0,0 +1,5 @@ +--- +layout: default +--- + +{% include section.html %} diff --git a/docs/expensify-classic/hubs/get-paid-back.html b/docs/expensify-classic/hubs/get-paid-back/index.html similarity index 100% rename from docs/expensify-classic/hubs/get-paid-back.html rename to docs/expensify-classic/hubs/get-paid-back/index.html diff --git a/docs/expensify-classic/hubs/get-paid-back/reports.html b/docs/expensify-classic/hubs/get-paid-back/reports.html new file mode 100644 index 000000000000..86641ee60b7d --- /dev/null +++ b/docs/expensify-classic/hubs/get-paid-back/reports.html @@ -0,0 +1,5 @@ +--- +layout: default +--- + +{% include section.html %} diff --git a/docs/expensify-classic/hubs/getting-started/approved-accountants.html b/docs/expensify-classic/hubs/getting-started/approved-accountants.html new file mode 100644 index 000000000000..86641ee60b7d --- /dev/null +++ b/docs/expensify-classic/hubs/getting-started/approved-accountants.html @@ -0,0 +1,5 @@ +--- +layout: default +--- + +{% include section.html %} diff --git a/docs/expensify-classic/hubs/getting-started.html b/docs/expensify-classic/hubs/getting-started/index.html similarity index 100% rename from docs/expensify-classic/hubs/getting-started.html rename to docs/expensify-classic/hubs/getting-started/index.html diff --git a/docs/expensify-classic/hubs/getting-started/playbooks.html b/docs/expensify-classic/hubs/getting-started/playbooks.html new file mode 100644 index 000000000000..86641ee60b7d --- /dev/null +++ b/docs/expensify-classic/hubs/getting-started/playbooks.html @@ -0,0 +1,5 @@ +--- +layout: default +--- + +{% include section.html %} diff --git a/docs/expensify-classic/hubs/getting-started/support.html b/docs/expensify-classic/hubs/getting-started/support.html new file mode 100644 index 000000000000..86641ee60b7d --- /dev/null +++ b/docs/expensify-classic/hubs/getting-started/support.html @@ -0,0 +1,5 @@ +--- +layout: default +--- + +{% include section.html %} diff --git a/docs/expensify-classic/hubs/getting-started/tips-and-tricks.html b/docs/expensify-classic/hubs/getting-started/tips-and-tricks.html new file mode 100644 index 000000000000..86641ee60b7d --- /dev/null +++ b/docs/expensify-classic/hubs/getting-started/tips-and-tricks.html @@ -0,0 +1,5 @@ +--- +layout: default +--- + +{% include section.html %} diff --git a/docs/expensify-classic/hubs/integrations/HR-integrations.html b/docs/expensify-classic/hubs/integrations/HR-integrations.html new file mode 100644 index 000000000000..86641ee60b7d --- /dev/null +++ b/docs/expensify-classic/hubs/integrations/HR-integrations.html @@ -0,0 +1,5 @@ +--- +layout: default +--- + +{% include section.html %} diff --git a/docs/expensify-classic/hubs/integrations/accounting-integrations.html b/docs/expensify-classic/hubs/integrations/accounting-integrations.html new file mode 100644 index 000000000000..86641ee60b7d --- /dev/null +++ b/docs/expensify-classic/hubs/integrations/accounting-integrations.html @@ -0,0 +1,5 @@ +--- +layout: default +--- + +{% include section.html %} diff --git a/docs/expensify-classic/hubs/integrations.html b/docs/expensify-classic/hubs/integrations/index.html similarity index 100% rename from docs/expensify-classic/hubs/integrations.html rename to docs/expensify-classic/hubs/integrations/index.html diff --git a/docs/expensify-classic/hubs/integrations/other-integrations.html b/docs/expensify-classic/hubs/integrations/other-integrations.html new file mode 100644 index 000000000000..86641ee60b7d --- /dev/null +++ b/docs/expensify-classic/hubs/integrations/other-integrations.html @@ -0,0 +1,5 @@ +--- +layout: default +--- + +{% include section.html %} diff --git a/docs/expensify-classic/hubs/integrations/travel-integrations.html b/docs/expensify-classic/hubs/integrations/travel-integrations.html new file mode 100644 index 000000000000..86641ee60b7d --- /dev/null +++ b/docs/expensify-classic/hubs/integrations/travel-integrations.html @@ -0,0 +1,5 @@ +--- +layout: default +--- + +{% include section.html %} diff --git a/docs/expensify-classic/hubs/manage-employees-and-report-approvals.html b/docs/expensify-classic/hubs/manage-employees-and-report-approvals/index.html similarity index 100% rename from docs/expensify-classic/hubs/manage-employees-and-report-approvals.html rename to docs/expensify-classic/hubs/manage-employees-and-report-approvals/index.html diff --git a/docs/expensify-classic/hubs/policy-and-domain-settings.html b/docs/expensify-classic/hubs/policy-and-domain-settings/index.html similarity index 100% rename from docs/expensify-classic/hubs/policy-and-domain-settings.html rename to docs/expensify-classic/hubs/policy-and-domain-settings/index.html diff --git a/docs/expensify-classic/hubs/send-payments.html b/docs/expensify-classic/hubs/send-payments/index.html similarity index 100% rename from docs/expensify-classic/hubs/send-payments.html rename to docs/expensify-classic/hubs/send-payments/index.html diff --git a/docs/new-expensify/hubs/account-settings.html b/docs/new-expensify/hubs/account-settings/index.html similarity index 100% rename from docs/new-expensify/hubs/account-settings.html rename to docs/new-expensify/hubs/account-settings/index.html diff --git a/docs/new-expensify/hubs/bank-accounts-and-credit-cards.html b/docs/new-expensify/hubs/bank-accounts-and-credit-cards/index.html similarity index 100% rename from docs/new-expensify/hubs/bank-accounts-and-credit-cards.html rename to docs/new-expensify/hubs/bank-accounts-and-credit-cards/index.html diff --git a/docs/new-expensify/hubs/billing-and-plan-types.html b/docs/new-expensify/hubs/billing-and-plan-types/index.html similarity index 100% rename from docs/new-expensify/hubs/billing-and-plan-types.html rename to docs/new-expensify/hubs/billing-and-plan-types/index.html diff --git a/docs/new-expensify/hubs/expense-and-report-features.html b/docs/new-expensify/hubs/expense-and-report-features/index.html similarity index 100% rename from docs/new-expensify/hubs/expense-and-report-features.html rename to docs/new-expensify/hubs/expense-and-report-features/index.html diff --git a/docs/new-expensify/hubs/expensify-card.html b/docs/new-expensify/hubs/expensify-card/index.html similarity index 100% rename from docs/new-expensify/hubs/expensify-card.html rename to docs/new-expensify/hubs/expensify-card/index.html diff --git a/docs/new-expensify/hubs/exports.html b/docs/new-expensify/hubs/exports/index.html similarity index 100% rename from docs/new-expensify/hubs/exports.html rename to docs/new-expensify/hubs/exports/index.html diff --git a/docs/new-expensify/hubs/get-paid-back.html b/docs/new-expensify/hubs/get-paid-back/index.html similarity index 100% rename from docs/new-expensify/hubs/get-paid-back.html rename to docs/new-expensify/hubs/get-paid-back/index.html diff --git a/docs/new-expensify/hubs/getting-started/chat.html b/docs/new-expensify/hubs/getting-started/chat.html new file mode 100644 index 000000000000..86641ee60b7d --- /dev/null +++ b/docs/new-expensify/hubs/getting-started/chat.html @@ -0,0 +1,5 @@ +--- +layout: default +--- + +{% include section.html %} diff --git a/docs/new-expensify/hubs/getting-started.html b/docs/new-expensify/hubs/getting-started/index.html similarity index 100% rename from docs/new-expensify/hubs/getting-started.html rename to docs/new-expensify/hubs/getting-started/index.html diff --git a/docs/new-expensify/hubs/integrations/accounting-integrations.html b/docs/new-expensify/hubs/integrations/accounting-integrations.html new file mode 100644 index 000000000000..86641ee60b7d --- /dev/null +++ b/docs/new-expensify/hubs/integrations/accounting-integrations.html @@ -0,0 +1,5 @@ +--- +layout: default +--- + +{% include section.html %} diff --git a/docs/new-expensify/hubs/integrations.html b/docs/new-expensify/hubs/integrations/index.html similarity index 100% rename from docs/new-expensify/hubs/integrations.html rename to docs/new-expensify/hubs/integrations/index.html diff --git a/docs/new-expensify/hubs/manage-employees-and-report-approvals.html b/docs/new-expensify/hubs/manage-employees-and-report-approvals/index.html similarity index 100% rename from docs/new-expensify/hubs/manage-employees-and-report-approvals.html rename to docs/new-expensify/hubs/manage-employees-and-report-approvals/index.html diff --git a/docs/new-expensify/hubs/send-payments.html b/docs/new-expensify/hubs/send-payments/index.html similarity index 100% rename from docs/new-expensify/hubs/send-payments.html rename to docs/new-expensify/hubs/send-payments/index.html diff --git a/docs/new-expensify/hubs/workspace-and-domain-settings.html b/docs/new-expensify/hubs/workspace-and-domain-settings/index.html similarity index 100% rename from docs/new-expensify/hubs/workspace-and-domain-settings.html rename to docs/new-expensify/hubs/workspace-and-domain-settings/index.html diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index 49e9d8cc5a7c..73e22053eda1 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -19,7 +19,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.3.73 + 1.3.74 CFBundleSignature ???? CFBundleURLTypes @@ -40,7 +40,7 @@ CFBundleVersion - 1.3.73.1 + 1.3.74.3 ITSAppUsesNonExemptEncryption LSApplicationQueriesSchemes diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index 20ba9c9d52aa..5e7f02699579 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -15,10 +15,10 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 1.3.73 + 1.3.74 CFBundleSignature ???? CFBundleVersion - 1.3.73.1 + 1.3.74.3 diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 51b9f6af0e21..ba53d939e46c 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -783,7 +783,7 @@ PODS: - React-Core - RNReactNativeHapticFeedback (1.14.0): - React-Core - - RNReanimated (3.4.0): + - RNReanimated (3.5.4): - DoubleConversion - FBLazyVector - glog @@ -1298,7 +1298,7 @@ SPEC CHECKSUMS: rnmapbox-maps: 6f638ec002aa6e906a6f766d69cd45f968d98e64 RNPermissions: dcdb7b99796bbeda6975a6e79ad519c41b251b1c RNReactNativeHapticFeedback: 1e3efeca9628ff9876ee7cdd9edec1b336913f8c - RNReanimated: 020859659f64be2d30849a1fe88c821a7c3e0cbf + RNReanimated: ab2e96c6d5591c3dfbb38a464f54c8d17fb34a87 RNScreens: d037903436160a4b039d32606668350d2a808806 RNSVG: ed492aaf3af9ca01bc945f7a149d76d62e73ec82 SDWebImage: a7f831e1a65eb5e285e3fb046a23fcfbf08e696d diff --git a/package-lock.json b/package-lock.json index 03a97f23ae45..8c63ba6ce9b3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "1.3.73-1", + "version": "1.3.74-3", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "1.3.73-1", + "version": "1.3.74-3", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -50,7 +50,7 @@ "date-fns-tz": "^2.0.0", "dom-serializer": "^0.2.2", "domhandler": "^4.3.0", - "expensify-common": "git+ssh://git@github.com/Expensify/expensify-common.git#35bff866a8d345b460ea6256f0a0f0a8a7f81086", + "expensify-common": "git+ssh://git@github.com/Expensify/expensify-common.git#1609f1848cc0c2528064519c3ea48b4953a708ee", "fbjs": "^3.0.2", "htmlparser2": "^7.2.0", "idb-keyval": "^6.2.1", @@ -90,7 +90,7 @@ "react-native-linear-gradient": "^2.8.1", "react-native-localize": "^2.2.6", "react-native-modal": "^13.0.0", - "react-native-onyx": "1.0.87", + "react-native-onyx": "1.0.89", "react-native-pager-view": "^6.2.0", "react-native-pdf": "^6.7.1", "react-native-performance": "^5.1.0", @@ -99,7 +99,7 @@ "react-native-plaid-link-sdk": "^10.0.0", "react-native-qrcode-svg": "^6.2.0", "react-native-quick-sqlite": "^8.0.0-beta.2", - "react-native-reanimated": "3.4.0", + "react-native-reanimated": "3.5.4", "react-native-render-html": "6.3.1", "react-native-safe-area-context": "4.4.1", "react-native-screens": "3.21.0", @@ -1777,10 +1777,11 @@ } }, "node_modules/@babel/plugin-transform-object-assign": { - "version": "7.18.6", - "license": "MIT", + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-assign/-/plugin-transform-object-assign-7.22.5.tgz", + "integrity": "sha512-iDhx9ARkXq4vhZ2CYOSnQXkmxkDgosLi3J8Z17mKz7LyzthtkdVchLD7WZ3aXeCuvJDOW3+1I5TpJmwIbF9MKQ==", "dependencies": { - "@babel/helper-plugin-utils": "^7.18.6" + "@babel/helper-plugin-utils": "^7.22.5" }, "engines": { "node": ">=6.9.0" @@ -28106,8 +28107,8 @@ }, "node_modules/expensify-common": { "version": "1.0.0", - "resolved": "git+ssh://git@github.com/Expensify/expensify-common.git#35bff866a8d345b460ea6256f0a0f0a8a7f81086", - "integrity": "sha512-O0KbaMljSFyoZXcxtx5B2qBL+n0md3wYOrArMVT8N0W+4wKncn+p8/vrmVlUMHe/vDloUbVmUZNdHwOZNWdx3w==", + "resolved": "git+ssh://git@github.com/Expensify/expensify-common.git#1609f1848cc0c2528064519c3ea48b4953a708ee", + "integrity": "sha512-sUd/ky6xCB/mShVaD2nVkedGL2xy+h6Jf5MfX9GOiYX8wB2D8uZSpqswz515uwcp8RDWrA5wxM2cR6pBXNfgxw==", "license": "MIT", "dependencies": { "classnames": "2.3.1", @@ -41203,9 +41204,9 @@ } }, "node_modules/react-native-onyx": { - "version": "1.0.87", - "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-1.0.87.tgz", - "integrity": "sha512-6mIhobSwpClDDGnJm9XEdjnpEdWfFesJ18J8Ifsb4tL6AVi+uxos5bnlZcOoMbtlUk3UozrgSyTjMfFrkD/aZA==", + "version": "1.0.89", + "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-1.0.89.tgz", + "integrity": "sha512-bSC8YwVbMBJYm6BMtuhuYmZi6zMh13e1t8Kaxp7K5EDLcSoTWsWPkuWX4wBvewlkLfw+HgB1IdgnXpa6+jS+ag==", "dependencies": { "ascii-table": "0.0.9", "fast-equals": "^4.0.3", @@ -41334,9 +41335,9 @@ } }, "node_modules/react-native-reanimated": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-3.4.0.tgz", - "integrity": "sha512-B5cZJseoIkYlZTRBRN0xuU1NBxUza/6GSHhiEBQfbOufWVlUMMcWUecIRVglW49l8d2wXbfCdQlNyVoFqmHkaQ==", + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-3.5.4.tgz", + "integrity": "sha512-8we9LLDO1o4Oj9/DICeEJ2K1tjfqkJagqQUglxeUAkol/HcEJ6PGxIrpBcNryLqCDYEcu6FZWld/FzizBIw6bg==", "dependencies": { "@babel/plugin-transform-object-assign": "^7.16.7", "@babel/preset-typescript": "^7.16.7", @@ -41356,7 +41357,8 @@ }, "node_modules/react-native-reanimated/node_modules/convert-source-map": { "version": "2.0.0", - "license": "MIT" + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==" }, "node_modules/react-native-render-html": { "version": "6.3.1", @@ -49776,9 +49778,11 @@ } }, "@babel/plugin-transform-object-assign": { - "version": "7.18.6", + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-assign/-/plugin-transform-object-assign-7.22.5.tgz", + "integrity": "sha512-iDhx9ARkXq4vhZ2CYOSnQXkmxkDgosLi3J8Z17mKz7LyzthtkdVchLD7WZ3aXeCuvJDOW3+1I5TpJmwIbF9MKQ==", "requires": { - "@babel/helper-plugin-utils": "^7.18.6" + "@babel/helper-plugin-utils": "^7.22.5" } }, "@babel/plugin-transform-object-rest-spread": { @@ -68254,9 +68258,9 @@ } }, "expensify-common": { - "version": "git+ssh://git@github.com/Expensify/expensify-common.git#35bff866a8d345b460ea6256f0a0f0a8a7f81086", - "integrity": "sha512-O0KbaMljSFyoZXcxtx5B2qBL+n0md3wYOrArMVT8N0W+4wKncn+p8/vrmVlUMHe/vDloUbVmUZNdHwOZNWdx3w==", - "from": "expensify-common@git+ssh://git@github.com/Expensify/expensify-common.git#35bff866a8d345b460ea6256f0a0f0a8a7f81086", + "version": "git+ssh://git@github.com/Expensify/expensify-common.git#1609f1848cc0c2528064519c3ea48b4953a708ee", + "integrity": "sha512-sUd/ky6xCB/mShVaD2nVkedGL2xy+h6Jf5MfX9GOiYX8wB2D8uZSpqswz515uwcp8RDWrA5wxM2cR6pBXNfgxw==", + "from": "expensify-common@git+ssh://git@github.com/Expensify/expensify-common.git#1609f1848cc0c2528064519c3ea48b4953a708ee", "requires": { "classnames": "2.3.1", "clipboard": "2.0.4", @@ -77265,9 +77269,9 @@ } }, "react-native-onyx": { - "version": "1.0.87", - "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-1.0.87.tgz", - "integrity": "sha512-6mIhobSwpClDDGnJm9XEdjnpEdWfFesJ18J8Ifsb4tL6AVi+uxos5bnlZcOoMbtlUk3UozrgSyTjMfFrkD/aZA==", + "version": "1.0.89", + "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-1.0.89.tgz", + "integrity": "sha512-bSC8YwVbMBJYm6BMtuhuYmZi6zMh13e1t8Kaxp7K5EDLcSoTWsWPkuWX4wBvewlkLfw+HgB1IdgnXpa6+jS+ag==", "requires": { "ascii-table": "0.0.9", "fast-equals": "^4.0.3", @@ -77328,9 +77332,9 @@ "requires": {} }, "react-native-reanimated": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-3.4.0.tgz", - "integrity": "sha512-B5cZJseoIkYlZTRBRN0xuU1NBxUza/6GSHhiEBQfbOufWVlUMMcWUecIRVglW49l8d2wXbfCdQlNyVoFqmHkaQ==", + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-3.5.4.tgz", + "integrity": "sha512-8we9LLDO1o4Oj9/DICeEJ2K1tjfqkJagqQUglxeUAkol/HcEJ6PGxIrpBcNryLqCDYEcu6FZWld/FzizBIw6bg==", "requires": { "@babel/plugin-transform-object-assign": "^7.16.7", "@babel/preset-typescript": "^7.16.7", @@ -77339,7 +77343,9 @@ }, "dependencies": { "convert-source-map": { - "version": "2.0.0" + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==" } } }, diff --git a/package.json b/package.json index e07431bfbd4d..d013caa1c402 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "1.3.73-1", + "version": "1.3.74-3", "author": "Expensify, Inc.", "homepage": "https://new.expensify.com", "description": "New Expensify is the next generation of Expensify: a reimagination of payments based atop a foundation of chat.", @@ -62,9 +62,9 @@ "@formatjs/intl-pluralrules": "^5.2.2", "@gorhom/portal": "^1.0.14", "@invertase/react-native-apple-authentication": "^2.2.2", - "@oguzhnatly/react-native-image-manipulator": "github:Expensify/react-native-image-manipulator#5cdae3d4455b03a04c57f50be3863e2fe6c92c52", "@kie/act-js": "^2.0.1", "@kie/mock-github": "^1.0.0", + "@oguzhnatly/react-native-image-manipulator": "github:Expensify/react-native-image-manipulator#5cdae3d4455b03a04c57f50be3863e2fe6c92c52", "@onfido/react-native-sdk": "7.4.0", "@react-native-async-storage/async-storage": "^1.17.10", "@react-native-camera-roll/camera-roll": "5.4.0", @@ -82,9 +82,9 @@ "@react-navigation/stack": "6.3.16", "@react-ng/bounds-observer": "^0.2.1", "@rnmapbox/maps": "^10.0.11", + "@types/node": "^18.14.0", "@ua/react-native-airship": "^15.2.6", "awesome-phonenumber": "^5.4.0", - "@types/node": "^18.14.0", "babel-plugin-transform-remove-console": "^6.9.4", "babel-polyfill": "^6.26.0", "canvas-size": "^1.2.6", @@ -93,7 +93,7 @@ "date-fns-tz": "^2.0.0", "dom-serializer": "^0.2.2", "domhandler": "^4.3.0", - "expensify-common": "git+ssh://git@github.com/Expensify/expensify-common.git#35bff866a8d345b460ea6256f0a0f0a8a7f81086", + "expensify-common": "git+ssh://git@github.com/Expensify/expensify-common.git#1609f1848cc0c2528064519c3ea48b4953a708ee", "fbjs": "^3.0.2", "htmlparser2": "^7.2.0", "idb-keyval": "^6.2.1", @@ -133,7 +133,7 @@ "react-native-linear-gradient": "^2.8.1", "react-native-localize": "^2.2.6", "react-native-modal": "^13.0.0", - "react-native-onyx": "1.0.87", + "react-native-onyx": "1.0.89", "react-native-pager-view": "^6.2.0", "react-native-pdf": "^6.7.1", "react-native-performance": "^5.1.0", @@ -142,7 +142,7 @@ "react-native-plaid-link-sdk": "^10.0.0", "react-native-qrcode-svg": "^6.2.0", "react-native-quick-sqlite": "^8.0.0-beta.2", - "react-native-reanimated": "3.4.0", + "react-native-reanimated": "3.5.4", "react-native-render-html": "6.3.1", "react-native-safe-area-context": "4.4.1", "react-native-screens": "3.21.0", diff --git a/src/CONST.ts b/src/CONST.ts index 3a198aca2c8c..dbe47c6ed1a7 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -440,6 +440,12 @@ const CONST = { INTERNAL_DEV_EXPENSIFY_URL: 'https://www.expensify.com.dev', STAGING_EXPENSIFY_URL: 'https://staging.expensify.com', EXPENSIFY_URL: 'https://www.expensify.com', + BANK_ACCOUNT_PERSONAL_DOCUMENTATION_INFO_URL: + 'https://community.expensify.com/discussion/6983/faq-why-do-i-need-to-provide-personal-documentation-when-setting-up-updating-my-bank-account', + PERSONAL_DATA_PROTECTION_INFO_URL: 'https://community.expensify.com/discussion/5677/deep-dive-security-how-expensify-protects-your-information', + ONFIDO_FACIAL_SCAN_POLICY_URL: 'https://onfido.com/facial-scan-policy-and-release/', + ONFIDO_PRIVACY_POLICY_URL: 'https://onfido.com/privacy/', + ONFIDO_TERMS_OF_SERVICE_URL: 'https://onfido.com/terms-of-service/', // Use Environment.getEnvironmentURL to get the complete URL with port number DEV_NEW_EXPENSIFY_URL: 'http://localhost:', @@ -734,7 +740,7 @@ const CONST = { }, DEFAULT_TIME_ZONE: {automatic: true, selected: 'America/Los_Angeles'}, DEFAULT_ACCOUNT_DATA: {errors: null, success: '', isLoading: false}, - DEFAULT_CLOSE_ACCOUNT_DATA: {errors: {}, success: '', isLoading: false}, + DEFAULT_CLOSE_ACCOUNT_DATA: {errors: null, success: '', isLoading: false}, FORMS: { LOGIN_FORM: 'LoginForm', VALIDATE_CODE_FORM: 'ValidateCodeForm', @@ -982,6 +988,10 @@ const CONST = { GOLD: 'GOLD', SILVER: 'SILVER', }, + WEB_MESSAGE_TYPE: { + STATEMENT: 'STATEMENT_NAVIGATE', + CONCIERGE: 'CONCIERGE_NAVIGATE', + }, }, PLAID: { @@ -1222,6 +1232,7 @@ const CONST = { EMOJI_NAME: /:[\w+-]+:/g, EMOJI_SUGGESTIONS: /:[a-zA-Z0-9_+-]{1,40}$/, AFTER_FIRST_LINE_BREAK: /\n.*/g, + LINE_BREAK: /\n/g, CODE_2FA: /^\d{6}$/, ATTACHMENT_ID: /chat-attachments\/(\d+)/, HAS_COLON_ONLY_AT_THE_BEGINNING: /^:[^:]+$/, @@ -1229,7 +1240,7 @@ const CONST = { SPECIAL_CHAR_OR_EMOJI: // eslint-disable-next-line no-misleading-character-class - /[\n\s,/?"{}[\]()&^%\\;`$=#<>!*\p{Extended_Pictographic}\u200d\u{1f1e6}-\u{1f1ff}\u{1f3fb}-\u{1f3ff}\u{e0020}-\u{e007f}\u20E3\uFE0F]|[#*0-9]\uFE0F?\u20E3/gu, + /[\n\s,/?"{}[\]()&_~^%\\;`$=#<>!*\p{Extended_Pictographic}\u200d\u{1f1e6}-\u{1f1ff}\u{1f3fb}-\u{1f3ff}\u{e0020}-\u{e007f}\u20E3\uFE0F]|[#*0-9]\uFE0F?\u20E3/gu, SPACE_OR_EMOJI: // eslint-disable-next-line no-misleading-character-class @@ -1359,6 +1370,8 @@ const CONST = { MERCHANT: 'merchant', CATEGORY: 'category', RECEIPT: 'receipt', + DISTANCE: 'distance', + TAG: 'tag', }, FOOTER: { EXPENSE_MANAGEMENT_URL: `${USE_EXPENSIFY_URL}/expense-management`, diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 8f95dff079fc..d2b3031220f1 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -242,6 +242,7 @@ const ONYXKEYS = { POLICY_RECENTLY_USED_TAGS: 'policyRecentlyUsedTags_', WORKSPACE_INVITE_MEMBERS_DRAFT: 'workspaceInviteMembersDraft_', REPORT: 'report_', + REPORT_METADATA: 'reportMetadata_', REPORT_ACTIONS: 'reportActions_', REPORT_ACTIONS_DRAFTS: 'reportActionsDrafts_', REPORT_ACTIONS_REACTIONS: 'reportActionsReactions_', @@ -329,7 +330,7 @@ type OnyxValues = { [ONYXKEYS.NVP_BLOCKED_FROM_CONCIERGE]: OnyxTypes.BlockedFromConcierge; [ONYXKEYS.NVP_PRIVATE_PUSH_NOTIFICATION_ID]: string; [ONYXKEYS.NVP_LAST_PAYMENT_METHOD]: Record; - [ONYXKEYS.NVP_RECENT_WAYPOINTS]: OnyxTypes.RecentWaypoints[]; + [ONYXKEYS.NVP_RECENT_WAYPOINTS]: OnyxTypes.RecentWaypoint[]; [ONYXKEYS.PUSH_NOTIFICATIONS_ENABLED]: boolean; [ONYXKEYS.PLAID_DATA]: OnyxTypes.PlaidData; [ONYXKEYS.IS_PLAID_DISABLED]: boolean; @@ -380,6 +381,7 @@ type OnyxValues = { [ONYXKEYS.COLLECTION.DEPRECATED_POLICY_MEMBER_LIST]: OnyxTypes.PolicyMember; [ONYXKEYS.COLLECTION.WORKSPACE_INVITE_MEMBERS_DRAFT]: Record; [ONYXKEYS.COLLECTION.REPORT]: OnyxTypes.Report; + [ONYXKEYS.COLLECTION.REPORT_METADATA]: OnyxTypes.ReportMetadata; [ONYXKEYS.COLLECTION.REPORT_ACTIONS]: OnyxTypes.ReportAction; [ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS]: string; [ONYXKEYS.COLLECTION.REPORT_ACTIONS_REACTIONS]: OnyxTypes.ReportActionReactions; diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 1065b525f83a..b2dafa643b22 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -5,151 +5,317 @@ import CONST from './CONST'; * This is a file containing constants for all of the routes we want to be able to go to */ -// prettier-ignore export default { - HOME: '', + HOME: '', /** This is a utility route used to go to the user's concierge chat, or the sign-in page if the user's not authenticated */ - CONCIERGE: 'concierge', - FLAG_COMMENT: { route: 'flag/:reportID/:reportActionID', getRoute: (reportID: string, reportActionID: string) => `flag/${reportID}/${reportActionID}`}, - SEARCH: 'search', - DETAILS: { route: 'details', getRoute: (login: string) => `details?login=${encodeURIComponent(login)}`}, - PROFILE: { route: 'a/:accountID', getRoute: (accountID: string | number, backTo = '') => { - const backToParam = backTo ? `?backTo=${encodeURIComponent(backTo)}` : ''; - return `a/${accountID}${backToParam}`; - }}, + CONCIERGE: 'concierge', + FLAG_COMMENT: { + route: 'flag/:reportID/:reportActionID', + getRoute: (reportID: string, reportActionID: string) => `flag/${reportID}/${reportActionID}`, + }, + SEARCH: 'search', + DETAILS: { + route: 'details', + getRoute: (login: string) => `details?login=${encodeURIComponent(login)}`, + }, + PROFILE: { + route: 'a/:accountID', + getRoute: (accountID: string | number, backTo = '') => { + const backToParam = backTo ? `?backTo=${encodeURIComponent(backTo)}` : ''; + return `a/${accountID}${backToParam}`; + }, + }, - TRANSITION_BETWEEN_APPS: 'transition', - VALIDATE_LOGIN: 'v/:accountID/:validateCode', - GET_ASSISTANCE: { route: 'get-assistance/:taskID', getRoute: (taskID: string) => `get-assistance/${taskID}`}, - UNLINK_LOGIN: 'u/:accountID/:validateCode', - APPLE_SIGN_IN: 'sign-in-with-apple', - GOOGLE_SIGN_IN: 'sign-in-with-google', - DESKTOP_SIGN_IN_REDIRECT: 'desktop-signin-redirect', + TRANSITION_BETWEEN_APPS: 'transition', + VALIDATE_LOGIN: 'v/:accountID/:validateCode', + GET_ASSISTANCE: { + route: 'get-assistance/:taskID', + getRoute: (taskID: string) => `get-assistance/${taskID}`, + }, + UNLINK_LOGIN: 'u/:accountID/:validateCode', + APPLE_SIGN_IN: 'sign-in-with-apple', + GOOGLE_SIGN_IN: 'sign-in-with-google', + DESKTOP_SIGN_IN_REDIRECT: 'desktop-signin-redirect', // This is a special validation URL that will take the user to /workspace/new after validation. This is used // when linking users from e.com in order to share a session in this app. - ENABLE_PAYMENTS: 'enable-payments', - WALLET_STATEMENT_WITH_DATE: 'statements/:yearMonth', - SIGN_IN_MODAL: 'sign-in-modal', + ENABLE_PAYMENTS: 'enable-payments', + WALLET_STATEMENT_WITH_DATE: 'statements/:yearMonth', + SIGN_IN_MODAL: 'sign-in-modal', - BANK_ACCOUNT: 'bank-account', - BANK_ACCOUNT_NEW: 'bank-account/new', - BANK_ACCOUNT_PERSONAL: 'bank-account/personal', - BANK_ACCOUNT_WITH_STEP_TO_OPEN: { route: 'bank-account/:stepToOpen?', getRoute: (stepToOpen = '', policyID = '', backTo = ''): string => { - const backToParam = backTo ? `&backTo=${encodeURIComponent(backTo)}` : ''; - return `bank-account/${stepToOpen}?policyID=${policyID}${backToParam}`; - }}, + BANK_ACCOUNT: 'bank-account', + BANK_ACCOUNT_NEW: 'bank-account/new', + BANK_ACCOUNT_PERSONAL: 'bank-account/personal', + BANK_ACCOUNT_WITH_STEP_TO_OPEN: { + route: 'bank-account/:stepToOpen?', + getRoute: (stepToOpen = '', policyID = '', backTo = ''): string => { + const backToParam = backTo ? `&backTo=${encodeURIComponent(backTo)}` : ''; + return `bank-account/${stepToOpen}?policyID=${policyID}${backToParam}`; + }, + }, - SETTINGS: 'settings', - SETTINGS_PROFILE: 'settings/profile', - SETTINGS_SHARE_CODE: 'settings/shareCode', - SETTINGS_DISPLAY_NAME: 'settings/profile/display-name', - SETTINGS_TIMEZONE: 'settings/profile/timezone', - SETTINGS_TIMEZONE_SELECT: 'settings/profile/timezone/select', - SETTINGS_PRONOUNS: 'settings/profile/pronouns', - SETTINGS_LOUNGE_ACCESS: 'settings/profile/lounge-access', - SETTINGS_PREFERENCES: 'settings/preferences', - SETTINGS_PRIORITY_MODE: 'settings/preferences/priority-mode', - SETTINGS_LANGUAGE: 'settings/preferences/language', - SETTINGS_THEME: 'settings/preferences/theme', - SETTINGS_WORKSPACES: 'settings/workspaces', - SETTINGS_SECURITY: 'settings/security', - SETTINGS_CLOSE: 'settings/security/closeAccount', - SETTINGS_ABOUT: 'settings/about', - SETTINGS_APP_DOWNLOAD_LINKS: 'settings/about/app-download-links', - SETTINGS_WALLET: 'settings/wallet', - SETTINGS_ADD_DEBIT_CARD: 'settings/wallet/add-debit-card', - SETTINGS_ADD_BANK_ACCOUNT: 'settings/wallet/add-bank-account', - SETTINGS_ENABLE_PAYMENTS: 'settings/wallet/enable-payments', - SETTINGS_WALLET_TRANSFER_BALANCE: 'settings/wallet/transfer-balance', - SETTINGS_WALLET_CHOOSE_TRANSFER_ACCOUNT: 'settings/wallet/choose-transfer-account', - SETTINGS_PERSONAL_DETAILS: 'settings/profile/personal-details', - SETTINGS_PERSONAL_DETAILS_LEGAL_NAME: 'settings/profile/personal-details/legal-name', - SETTINGS_PERSONAL_DETAILS_DATE_OF_BIRTH: 'settings/profile/personal-details/date-of-birth', - SETTINGS_PERSONAL_DETAILS_ADDRESS: 'settings/profile/personal-details/address', - SETTINGS_CONTACT_METHODS: 'settings/profile/contact-methods', - SETTINGS_CONTACT_METHOD_DETAILS: { route: 'settings/profile/contact-methods/:contactMethod/details', getRoute: (contactMethod: string) => `settings/profile/contact-methods/${encodeURIComponent(contactMethod)}/details`}, - SETTINGS_NEW_CONTACT_METHOD: 'settings/profile/contact-methods/new', - SETTINGS_2FA: 'settings/security/two-factor-auth', - SETTINGS_STATUS: 'settings/profile/status', - SETTINGS_STATUS_SET: 'settings/profile/status/set', + SETTINGS: 'settings', + SETTINGS_PROFILE: 'settings/profile', + SETTINGS_SHARE_CODE: 'settings/shareCode', + SETTINGS_DISPLAY_NAME: 'settings/profile/display-name', + SETTINGS_TIMEZONE: 'settings/profile/timezone', + SETTINGS_TIMEZONE_SELECT: 'settings/profile/timezone/select', + SETTINGS_PRONOUNS: 'settings/profile/pronouns', + SETTINGS_LOUNGE_ACCESS: 'settings/profile/lounge-access', + SETTINGS_PREFERENCES: 'settings/preferences', + SETTINGS_PRIORITY_MODE: 'settings/preferences/priority-mode', + SETTINGS_LANGUAGE: 'settings/preferences/language', + SETTINGS_THEME: 'settings/preferences/theme', + SETTINGS_WORKSPACES: 'settings/workspaces', + SETTINGS_SECURITY: 'settings/security', + SETTINGS_CLOSE: 'settings/security/closeAccount', + SETTINGS_ABOUT: 'settings/about', + SETTINGS_APP_DOWNLOAD_LINKS: 'settings/about/app-download-links', + SETTINGS_WALLET: 'settings/wallet', + SETTINGS_WALLET_DOMAINCARDS: { + route: '/settings/wallet/card/:domain', + getRoute: (domain: string) => `/settings/wallet/card/${domain}`, + }, + SETTINGS_ADD_DEBIT_CARD: 'settings/wallet/add-debit-card', + SETTINGS_ADD_BANK_ACCOUNT: 'settings/wallet/add-bank-account', + SETTINGS_ENABLE_PAYMENTS: 'settings/wallet/enable-payments', + SETTINGS_WALLET_TRANSFER_BALANCE: 'settings/wallet/transfer-balance', + SETTINGS_WALLET_CHOOSE_TRANSFER_ACCOUNT: 'settings/wallet/choose-transfer-account', + SETTINGS_PERSONAL_DETAILS: 'settings/profile/personal-details', + SETTINGS_PERSONAL_DETAILS_LEGAL_NAME: 'settings/profile/personal-details/legal-name', + SETTINGS_PERSONAL_DETAILS_DATE_OF_BIRTH: 'settings/profile/personal-details/date-of-birth', + SETTINGS_PERSONAL_DETAILS_ADDRESS: 'settings/profile/personal-details/address', + SETTINGS_PERSONAL_DETAILS_ADDRESS_COUNTRY: { + route: 'settings/profile/personal-details/address/country', + getRoute: (country: string, backTo?: string) => { + let route = `settings/profile/personal-details/address/country?country=${country}`; + if (backTo) { + route += `&backTo=${encodeURIComponent(backTo)}`; + } + return route; + }, + }, + SETTINGS_CONTACT_METHODS: 'settings/profile/contact-methods', + SETTINGS_CONTACT_METHOD_DETAILS: { + route: 'settings/profile/contact-methods/:contactMethod/details', + getRoute: (contactMethod: string) => `settings/profile/contact-methods/${encodeURIComponent(contactMethod)}/details`, + }, + SETTINGS_NEW_CONTACT_METHOD: 'settings/profile/contact-methods/new', + SETTINGS_2FA: 'settings/security/two-factor-auth', + SETTINGS_STATUS: 'settings/profile/status', + SETTINGS_STATUS_SET: 'settings/profile/status/set', - NEW: 'new', - NEW_CHAT: 'new/chat', - NEW_ROOM: 'new/room', + NEW: 'new', + NEW_CHAT: 'new/chat', + NEW_ROOM: 'new/room', - REPORT: 'r', - REPORT_WITH_ID: { route: 'r/:reportID?/:reportActionID?', getRoute: (reportID: string) => `r/${reportID}`}, - EDIT_REQUEST: { route: 'r/:threadReportID/edit/:field', getRoute: (threadReportID: string, field: ValueOf) => `r/${threadReportID}/edit/${field}`}, - EDIT_CURRENCY_REQUEST: { route: 'r/:threadReportID/edit/currency', getRoute: (threadReportID: string, currency: string, backTo: string) => `r/${threadReportID}/edit/currency?currency=${currency}&backTo=${backTo}`}, - REPORT_WITH_ID_DETAILS_SHARE_CODE: { route: 'r/:reportID/details/shareCode', getRoute: (reportID: string) => `r/${reportID}/details/shareCode`}, - REPORT_ATTACHMENTS: { route: 'r/:reportID/attachment', getRoute: (reportID: string, source: string) => `r/${reportID}/attachment?source=${encodeURI(source)}`}, - REPORT_PARTICIPANTS: { route: 'r/:reportID/participants', getRoute: (reportID: string) => `r/${reportID}/participants`}, - REPORT_WITH_ID_DETAILS: { route: 'r/:reportID/details', getRoute: (reportID: string) => `r/${reportID}/details`}, - REPORT_SETTINGS: { route: 'r/:reportID/settings', getRoute: (reportID: string) => `r/${reportID}/settings`}, - REPORT_SETTINGS_ROOM_NAME: { route: 'r/:reportID/settings/room-name', getRoute: (reportID: string) => `r/${reportID}/settings/room-name`}, - REPORT_SETTINGS_NOTIFICATION_PREFERENCES: {route: 'r/:reportID/settings/notification-preferences', getRoute: (reportID: string) => `r/${reportID}/settings/notification-preferences`}, - REPORT_SETTINGS_WRITE_CAPABILITY: { route: 'r/:reportID/settings/who-can-post', getRoute: (reportID: string) => `r/${reportID}/settings/who-can-post`}, - REPORT_WELCOME_MESSAGE: { route: 'r/:reportID/welcomeMessage', getRoute: (reportID: string) => `r/${reportID}/welcomeMessage`}, - SPLIT_BILL_DETAILS: { route: 'r/:reportID/split/:reportActionID', getRoute: (reportID: string, reportActionID: string) => `r/${reportID}/split/${reportActionID}`}, - TASK_TITLE: { route: 'r/:reportID/title', getRoute: (reportID: string) => `r/${reportID}/title`}, - TASK_DESCRIPTION: { route: 'r/:reportID/description', getRoute: (reportID: string) => `r/${reportID}/description`}, - TASK_ASSIGNEE: { route: 'r/:reportID/assignee', getRoute: (reportID: string) => `r/${reportID}/assignee`}, - PRIVATE_NOTES_VIEW: { route: 'r/:reportID/notes/:accountID', getRoute: (reportID: string, accountID: string | number) => `r/${reportID}/notes/${accountID}`}, - PRIVATE_NOTES_LIST: { route: 'r/:reportID/notes', getRoute: (reportID: string) => `r/${reportID}/notes`}, - PRIVATE_NOTES_EDIT: { route: 'r/:reportID/notes/:accountID/edit', getRoute: (reportID: string, accountID: string | number) => `r/${reportID}/notes/${accountID}/edit`}, + REPORT: 'r', + REPORT_WITH_ID: { + route: 'r/:reportID?/:reportActionID?', + getRoute: (reportID: string) => `r/${reportID}`, + }, + EDIT_REQUEST: { + route: 'r/:threadReportID/edit/:field', + getRoute: (threadReportID: string, field: ValueOf) => `r/${threadReportID}/edit/${field}`, + }, + EDIT_CURRENCY_REQUEST: { + route: 'r/:threadReportID/edit/currency', + getRoute: (threadReportID: string, currency: string, backTo: string) => `r/${threadReportID}/edit/currency?currency=${currency}&backTo=${backTo}`, + }, + REPORT_WITH_ID_DETAILS_SHARE_CODE: { + route: 'r/:reportID/details/shareCode', + getRoute: (reportID: string) => `r/${reportID}/details/shareCode`, + }, + REPORT_ATTACHMENTS: { + route: 'r/:reportID/attachment', + getRoute: (reportID: string, source: string) => `r/${reportID}/attachment?source=${encodeURI(source)}`, + }, + REPORT_PARTICIPANTS: { + route: 'r/:reportID/participants', + getRoute: (reportID: string) => `r/${reportID}/participants`, + }, + REPORT_WITH_ID_DETAILS: { + route: 'r/:reportID/details', + getRoute: (reportID: string) => `r/${reportID}/details`, + }, + REPORT_SETTINGS: { + route: 'r/:reportID/settings', + getRoute: (reportID: string) => `r/${reportID}/settings`, + }, + REPORT_SETTINGS_ROOM_NAME: { + route: 'r/:reportID/settings/room-name', + getRoute: (reportID: string) => `r/${reportID}/settings/room-name`, + }, + REPORT_SETTINGS_NOTIFICATION_PREFERENCES: { + route: 'r/:reportID/settings/notification-preferences', + getRoute: (reportID: string) => `r/${reportID}/settings/notification-preferences`, + }, + REPORT_SETTINGS_WRITE_CAPABILITY: { + route: 'r/:reportID/settings/who-can-post', + getRoute: (reportID: string) => `r/${reportID}/settings/who-can-post`, + }, + REPORT_WELCOME_MESSAGE: { + route: 'r/:reportID/welcomeMessage', + getRoute: (reportID: string) => `r/${reportID}/welcomeMessage`, + }, + SPLIT_BILL_DETAILS: { + route: 'r/:reportID/split/:reportActionID', + getRoute: (reportID: string, reportActionID: string) => `r/${reportID}/split/${reportActionID}`, + }, + TASK_TITLE: { + route: 'r/:reportID/title', + getRoute: (reportID: string) => `r/${reportID}/title`, + }, + TASK_DESCRIPTION: { + route: 'r/:reportID/description', + getRoute: (reportID: string) => `r/${reportID}/description`, + }, + TASK_ASSIGNEE: { + route: 'r/:reportID/assignee', + getRoute: (reportID: string) => `r/${reportID}/assignee`, + }, + PRIVATE_NOTES_VIEW: { + route: 'r/:reportID/notes/:accountID', + getRoute: (reportID: string, accountID: string | number) => `r/${reportID}/notes/${accountID}`, + }, + PRIVATE_NOTES_LIST: { + route: 'r/:reportID/notes', + getRoute: (reportID: string) => `r/${reportID}/notes`, + }, + PRIVATE_NOTES_EDIT: { + route: 'r/:reportID/notes/:accountID/edit', + getRoute: (reportID: string, accountID: string | number) => `r/${reportID}/notes/${accountID}/edit`, + }, // To see the available iouType, please refer to CONST.IOU.MONEY_REQUEST_TYPE - MONEY_REQUEST: { route: ':iouType/new/:reportID?', getRoute: (iouType: string, reportID = '') => `${iouType}/new/${reportID}`}, - MONEY_REQUEST_AMOUNT: { route: ':iouType/new/amount/:reportID?', getRoute: (iouType: string, reportID = '') => `${iouType}/new/amount/${reportID}`}, - MONEY_REQUEST_PARTICIPANTS: { route: ':iouType/new/participants/:reportID?', getRoute: (iouType: string, reportID = '') => `${iouType}/new/participants/${reportID}`}, - MONEY_REQUEST_CONFIRMATION: { route: ':iouType/new/confirmation/:reportID?', getRoute: (iouType: string, reportID = '') => `${iouType}/new/confirmation/${reportID}`}, - MONEY_REQUEST_DATE: { route: ':iouType/new/date/:reportID?', getRoute: (iouType: string, reportID = '') => `${iouType}/new/date/${reportID}`}, - MONEY_REQUEST_CURRENCY: { route: ':iouType/new/currency/:reportID?', getRoute: (iouType: string, reportID: string, currency: string, backTo: string) => `${iouType}/new/currency/${reportID}?currency=${currency}&backTo=${backTo}`}, - MONEY_REQUEST_DESCRIPTION: { route: ':iouType/new/description/:reportID?', getRoute: (iouType: string, reportID = '') => `${iouType}/new/description/${reportID}`}, - MONEY_REQUEST_CATEGORY: { route: ':iouType/new/category/:reportID?', getRoute: (iouType: string, reportID = '') => `${iouType}/new/category/${reportID}`}, - MONEY_REQUEST_TAG: { route: ':iouType/new/tag/:reportID?', getRoute: (iouType: string, reportID = '') => `${iouType}/new/tag/${reportID}`}, - MONEY_REQUEST_MERCHANT: { route: ':iouType/new/merchant/:reportID?', getRoute: (iouType: string, reportID = '') => `${iouType}/new/merchant/${reportID}`}, - MONEY_REQUEST_WAYPOINT: { route: ':iouType/new/waypoint/:waypointIndex', getRoute: (iouType: string, waypointIndex: number) => `${iouType}/new/waypoint/${waypointIndex}`}, - MONEY_REQUEST_RECEIPT: { route: ':iouType/new/receipt/:reportID?', getRoute: (iouType: string, reportID = '') => `${iouType}/new/receipt/${reportID}`}, - MONEY_REQUEST_ADDRESS: { route: ':iouType/new/address/:reportID?', getRoute: (iouType: string, reportID = '') => `${iouType}/new/address/${reportID}`}, - MONEY_REQUEST_DISTANCE_TAB: { route: ':iouType/new/:reportID?/distance', getRoute: (iouType: string, reportID = '') => `${iouType}/new/${reportID}/distance`}, - MONEY_REQUEST_MANUAL_TAB: ':iouType/new/:reportID?/manual', - MONEY_REQUEST_SCAN_TAB: ':iouType/new/:reportID?/scan', + MONEY_REQUEST: { + route: ':iouType/new/:reportID?', + getRoute: (iouType: string, reportID = '') => `${iouType}/new/${reportID}`, + }, + MONEY_REQUEST_AMOUNT: { + route: ':iouType/new/amount/:reportID?', + getRoute: (iouType: string, reportID = '') => `${iouType}/new/amount/${reportID}`, + }, + MONEY_REQUEST_PARTICIPANTS: { + route: ':iouType/new/participants/:reportID?', + getRoute: (iouType: string, reportID = '') => `${iouType}/new/participants/${reportID}`, + }, + MONEY_REQUEST_CONFIRMATION: { + route: ':iouType/new/confirmation/:reportID?', + getRoute: (iouType: string, reportID = '') => `${iouType}/new/confirmation/${reportID}`, + }, + MONEY_REQUEST_DATE: { + route: ':iouType/new/date/:reportID?', + getRoute: (iouType: string, reportID = '') => `${iouType}/new/date/${reportID}`, + }, + MONEY_REQUEST_CURRENCY: { + route: ':iouType/new/currency/:reportID?', + getRoute: (iouType: string, reportID: string, currency: string, backTo: string) => `${iouType}/new/currency/${reportID}?currency=${currency}&backTo=${backTo}`, + }, + MONEY_REQUEST_DESCRIPTION: { + route: ':iouType/new/description/:reportID?', + getRoute: (iouType: string, reportID = '') => `${iouType}/new/description/${reportID}`, + }, + MONEY_REQUEST_CATEGORY: { + route: ':iouType/new/category/:reportID?', + getRoute: (iouType: string, reportID = '') => `${iouType}/new/category/${reportID}`, + }, + MONEY_REQUEST_TAG: { + route: ':iouType/new/tag/:reportID?', + getRoute: (iouType: string, reportID = '') => `${iouType}/new/tag/${reportID}`, + }, + MONEY_REQUEST_MERCHANT: { + route: ':iouType/new/merchant/:reportID?', + getRoute: (iouType: string, reportID = '') => `${iouType}/new/merchant/${reportID}`, + }, + MONEY_REQUEST_WAYPOINT: { + route: ':iouType/new/waypoint/:waypointIndex', + getRoute: (iouType: string, waypointIndex: number) => `${iouType}/new/waypoint/${waypointIndex}`, + }, + MONEY_REQUEST_RECEIPT: { + route: ':iouType/new/receipt/:reportID?', + getRoute: (iouType: string, reportID = '') => `${iouType}/new/receipt/${reportID}`, + }, + MONEY_REQUEST_DISTANCE: { + route: ':iouType/new/address/:reportID?', + getRoute: (iouType: string, reportID = '') => `${iouType}/new/address/${reportID}`, + }, + MONEY_REQUEST_EDIT_WAYPOINT: { + route: 'r/:threadReportID/edit/distance/:transactionID/waypoint/:waypointIndex', + getRoute: (threadReportID: number, transactionID: string, waypointIndex: number) => `r/${threadReportID}/edit/distance/${transactionID}/waypoint/${waypointIndex}`, + }, + MONEY_REQUEST_DISTANCE_TAB: { + route: ':iouType/new/:reportID?/distance', + getRoute: (iouType: string, reportID = '') => `${iouType}/new/${reportID}/distance`, + }, + MONEY_REQUEST_MANUAL_TAB: ':iouType/new/:reportID?/manual', + MONEY_REQUEST_SCAN_TAB: ':iouType/new/:reportID?/scan', - IOU_REQUEST: 'request/new', - IOU_SEND: 'send/new', - IOU_SEND_ADD_BANK_ACCOUNT: 'send/new/add-bank-account', - IOU_SEND_ADD_DEBIT_CARD: 'send/new/add-debit-card', - IOU_SEND_ENABLE_PAYMENTS: 'send/new/enable-payments', + IOU_REQUEST: 'request/new', + IOU_SEND: 'send/new', + IOU_SEND_ADD_BANK_ACCOUNT: 'send/new/add-bank-account', + IOU_SEND_ADD_DEBIT_CARD: 'send/new/add-debit-card', + IOU_SEND_ENABLE_PAYMENTS: 'send/new/enable-payments', - NEW_TASK: 'new/task', - NEW_TASK_WITH_REPORT_ID: 'new/task/:reportID?', - NEW_TASK_ASSIGNEE: 'new/task/assignee', - NEW_TASK_SHARE_DESTINATION: 'new/task/share-destination', - NEW_TASK_DETAILS: 'new/task/details', - NEW_TASK_TITLE: 'new/task/title', - NEW_TASK_DESCRIPTION: 'new/task/description', + NEW_TASK: 'new/task', + NEW_TASK_ASSIGNEE: 'new/task/assignee', + NEW_TASK_SHARE_DESTINATION: 'new/task/share-destination', + NEW_TASK_DETAILS: 'new/task/details', + NEW_TASK_TITLE: 'new/task/title', + NEW_TASK_DESCRIPTION: 'new/task/description', - TEACHERS_UNITE: 'teachersunite', - I_KNOW_A_TEACHER: 'teachersunite/i-know-a-teacher', - I_AM_A_TEACHER: 'teachersunite/i-am-a-teacher', - INTRO_SCHOOL_PRINCIPAL: 'teachersunite/intro-school-principal', + TEACHERS_UNITE: 'teachersunite', + I_KNOW_A_TEACHER: 'teachersunite/i-know-a-teacher', + I_AM_A_TEACHER: 'teachersunite/i-am-a-teacher', + INTRO_SCHOOL_PRINCIPAL: 'teachersunite/intro-school-principal', - WORKSPACE_NEW: 'workspace/new', - WORKSPACE_NEW_ROOM: 'workspace/new-room', - WORKSPACE_INITIAL: { route: 'workspace/:policyID', getRoute: (policyID: string) => `workspace/${policyID}`}, - WORKSPACE_INVITE: { route: 'workspace/:policyID/invite', getRoute: (policyID: string) => `workspace/${policyID}/invite`}, - WORKSPACE_INVITE_MESSAGE: { route: 'workspace/:policyID/invite-message', getRoute: (policyID: string) => `workspace/${policyID}/invite-message`}, - WORKSPACE_SETTINGS: { route: 'workspace/:policyID/settings', getRoute: (policyID: string) => `workspace/${policyID}/settings`}, - WORKSPACE_CARD: { route: 'workspace/:policyID/card', getRoute: (policyID: string) => `workspace/${policyID}/card`}, - WORKSPACE_REIMBURSE: { route: 'workspace/:policyID/reimburse', getRoute: (policyID: string) => `workspace/${policyID}/reimburse`}, - WORKSPACE_RATE_AND_UNIT: { route: 'workspace/:policyID/rateandunit', getRoute: (policyID: string) => `workspace/${policyID}/rateandunit`}, - WORKSPACE_BILLS: { route: 'workspace/:policyID/bills', getRoute: (policyID: string) => `workspace/${policyID}/bills`}, - WORKSPACE_INVOICES: { route: 'workspace/:policyID/invoices', getRoute: (policyID: string) => `workspace/${policyID}/invoices`}, - WORKSPACE_TRAVEL: { route: 'workspace/:policyID/travel', getRoute: (policyID: string) => `workspace/${policyID}/travel`}, - WORKSPACE_MEMBERS: { route: 'workspace/:policyID/members', getRoute: (policyID: string) => `workspace/${policyID}/members`}, + WORKSPACE_NEW: 'workspace/new', + WORKSPACE_NEW_ROOM: 'workspace/new-room', + WORKSPACE_INITIAL: { + route: 'workspace/:policyID', + getRoute: (policyID: string) => `workspace/${policyID}`, + }, + WORKSPACE_INVITE: { + route: 'workspace/:policyID/invite', + getRoute: (policyID: string) => `workspace/${policyID}/invite`, + }, + WORKSPACE_INVITE_MESSAGE: { + route: 'workspace/:policyID/invite-message', + getRoute: (policyID: string) => `workspace/${policyID}/invite-message`, + }, + WORKSPACE_SETTINGS: { + route: 'workspace/:policyID/settings', + getRoute: (policyID: string) => `workspace/${policyID}/settings`, + }, + WORKSPACE_CARD: { + route: 'workspace/:policyID/card', + getRoute: (policyID: string) => `workspace/${policyID}/card`, + }, + WORKSPACE_REIMBURSE: { + route: 'workspace/:policyID/reimburse', + getRoute: (policyID: string) => `workspace/${policyID}/reimburse`, + }, + WORKSPACE_RATE_AND_UNIT: { + route: 'workspace/:policyID/rateandunit', + getRoute: (policyID: string) => `workspace/${policyID}/rateandunit`, + }, + WORKSPACE_BILLS: { + route: 'workspace/:policyID/bills', + getRoute: (policyID: string) => `workspace/${policyID}/bills`, + }, + WORKSPACE_INVOICES: { + route: 'workspace/:policyID/invoices', + getRoute: (policyID: string) => `workspace/${policyID}/invoices`, + }, + WORKSPACE_TRAVEL: { + route: 'workspace/:policyID/travel', + getRoute: (policyID: string) => `workspace/${policyID}/travel`, + }, + WORKSPACE_MEMBERS: { + route: 'workspace/:policyID/members', + getRoute: (policyID: string) => `workspace/${policyID}/members`, + }, // These are some on-off routes that will be removed once they're no longer needed (see GH issues for details) - SAASTR: 'saastr', - SBE: 'sbe', + SAASTR: 'saastr', + SBE: 'sbe', } as const; diff --git a/src/components/AttachmentModal.js b/src/components/AttachmentModal.js index 3f89f4032061..23545de26cfd 100755 --- a/src/components/AttachmentModal.js +++ b/src/components/AttachmentModal.js @@ -30,6 +30,7 @@ import useWindowDimensions from '../hooks/useWindowDimensions'; import Navigation from '../libs/Navigation/Navigation'; import ROUTES from '../ROUTES'; import useNativeDriver from '../libs/useNativeDriver'; +import useNetwork from '../hooks/useNetwork'; /** * Modal render prop component that exposes modal launching triggers that can be used @@ -121,6 +122,7 @@ function AttachmentModal(props) { : undefined, ); const {translate} = useLocalize(); + const {isOffline} = useNetwork(); const onCarouselAttachmentChange = props.onCarouselAttachmentChange; @@ -350,7 +352,7 @@ function AttachmentModal(props) { downloadAttachment(source)} shouldShowCloseButton={!props.isSmallScreenWidth} shouldShowBackButton={props.isSmallScreenWidth} @@ -395,6 +397,7 @@ function AttachmentModal(props) { file={file} onToggleKeyboard={updateConfirmButtonVisibility} isWorkspaceAvatar={props.isWorkspaceAvatar} + fallbackSource={props.fallbackSource} /> ) )} diff --git a/src/components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPage.js b/src/components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPage.js index adee75cb4fa9..f7da8cfce894 100644 --- a/src/components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPage.js +++ b/src/components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPage.js @@ -1,5 +1,5 @@ /* eslint-disable es/no-optional-chaining */ -import React, {useContext, useEffect, useState} from 'react'; +import React, {useContext, useEffect, useRef, useState} from 'react'; import {ActivityIndicator, PixelRatio, StyleSheet, View} from 'react-native'; import PropTypes from 'prop-types'; import Image from '../../../Image'; @@ -49,16 +49,19 @@ function AttachmentCarouselPage({source, isAuthTokenRequired, isActive: initialI }, [initialIsActive]); const [initialActivePageLoad, setInitialActivePageLoad] = useState(isActive); - const [isImageLoading, setIsImageLoading] = useState(true); - const [showFallback, setShowFallback] = useState(isImageLoading); + const isImageLoaded = useRef(null); + const [isImageLoading, setIsImageLoading] = useState(false); + const [isFallbackLoading, setIsFallbackLoading] = useState(false); + const [showFallback, setShowFallback] = useState(true); // We delay hiding the fallback image while image transformer is still rendering useEffect(() => { - if (isImageLoading) { + if (isImageLoading || showFallback) { setShowFallback(true); } else { setTimeout(() => setShowFallback(false), 100); } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [isImageLoading]); return ( @@ -79,8 +82,14 @@ function AttachmentCarouselPage({source, isAuthTokenRequired, isActive: initialI source={{uri: source}} style={dimensions == null ? undefined : {width: dimensions.imageWidth, height: dimensions.imageHeight}} isAuthTokenRequired={isAuthTokenRequired} - onLoadStart={() => setIsImageLoading(true)} - onLoadEnd={() => setIsImageLoading(false)} + onLoadStart={() => { + setIsImageLoading(true); + }} + onLoadEnd={() => { + setShowFallback(false); + setIsImageLoading(false); + isImageLoaded.current = true; + }} onLoad={(evt) => { const imageWidth = (evt.nativeEvent?.width || 0) / PixelRatio.get(); const imageHeight = (evt.nativeEvent?.height || 0) / PixelRatio.get(); @@ -106,8 +115,10 @@ function AttachmentCarouselPage({source, isAuthTokenRequired, isActive: initialI // On the initial render of the active page, the onLoadEnd event is never fired. // That's why we instead set isImageLoading to false in the onLoad event. if (initialActivePageLoad) { - setIsImageLoading(false); setInitialActivePageLoad(false); + setIsImageLoading(false); + setTimeout(() => setShowFallback(false), 100); + isImageLoaded.current = true; } }} /> @@ -116,12 +127,24 @@ function AttachmentCarouselPage({source, isAuthTokenRequired, isActive: initialI )} {/* Keep rendering the image without gestures as fallback while ImageTransformer is loading the image */} - {(!isActive || showFallback) && ( + {(showFallback || !isActive) && ( setIsImageLoading(true)} + onLoadStart={() => { + setIsImageLoading(true); + if (isImageLoaded.current) { + return; + } + setIsFallbackLoading(true); + }} + onLoadEnd={() => { + if (isImageLoaded.current) { + return; + } + setIsFallbackLoading(false); + }} onLoad={(evt) => { const imageWidth = evt.nativeEvent.width; const imageHeight = evt.nativeEvent.height; @@ -149,7 +172,7 @@ function AttachmentCarouselPage({source, isAuthTokenRequired, isActive: initialI )} {/* Show activity indicator while ImageTransfomer is still loading the image. */} - {isActive && isImageLoading && ( + {isActive && isFallbackLoading && !isImageLoaded.current && ( ); return onPress ? ( diff --git a/src/components/Attachments/AttachmentView/index.js b/src/components/Attachments/AttachmentView/index.js index 47353d915060..1fc579977c9d 100755 --- a/src/components/Attachments/AttachmentView/index.js +++ b/src/components/Attachments/AttachmentView/index.js @@ -17,6 +17,7 @@ import AttachmentViewPdf from './AttachmentViewPdf'; import addEncryptedAuthTokenToURL from '../../../libs/addEncryptedAuthTokenToURL'; import * as StyleUtils from '../../../styles/StyleUtils'; import {attachmentViewPropTypes, attachmentViewDefaultProps} from './propTypes'; +import useNetwork from '../../../hooks/useNetwork'; const propTypes = { ...attachmentViewPropTypes, @@ -62,9 +63,14 @@ function AttachmentView({ translate, isFocused, isWorkspaceAvatar, + fallbackSource, }) { const [loadComplete, setLoadComplete] = useState(false); + const [imageError, setImageError] = useState(false); + + useNetwork({onReconnect: () => setImageError(false)}); + // Handles case where source is a component (ex: SVG) if (_.isFunction(source)) { let iconFillColor = ''; @@ -113,7 +119,7 @@ function AttachmentView({ if (isImage || (file && Str.isImage(file.name))) { return ( { + setImageError(true); + }} /> ); } diff --git a/src/components/AutoCompleteSuggestions/BaseAutoCompleteSuggestions.js b/src/components/AutoCompleteSuggestions/BaseAutoCompleteSuggestions.js index b4710f1f343e..40e08d876907 100644 --- a/src/components/AutoCompleteSuggestions/BaseAutoCompleteSuggestions.js +++ b/src/components/AutoCompleteSuggestions/BaseAutoCompleteSuggestions.js @@ -70,7 +70,7 @@ function BaseAutoCompleteSuggestions(props) { }); const innerHeight = CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTION_ROW_HEIGHT * props.suggestions.length; - const animatedStyles = useAnimatedStyle(() => StyleUtils.getAutoCompleteSuggestionContainerStyle(rowHeight.value, props.shouldIncludeReportRecipientLocalTimeHeight)); + const animatedStyles = useAnimatedStyle(() => StyleUtils.getAutoCompleteSuggestionContainerStyle(rowHeight.value)); useEffect(() => { rowHeight.value = withTiming(measureHeightOfSuggestionRows(props.suggestions.length, props.isSuggestionPickerLarge), { diff --git a/src/components/AutoCompleteSuggestions/autoCompleteSuggestionsPropTypes.js b/src/components/AutoCompleteSuggestions/autoCompleteSuggestionsPropTypes.js index 16040991a3d8..8c6dca1902c5 100644 --- a/src/components/AutoCompleteSuggestions/autoCompleteSuggestionsPropTypes.js +++ b/src/components/AutoCompleteSuggestions/autoCompleteSuggestionsPropTypes.js @@ -22,9 +22,6 @@ const propTypes = { * When this value is false, the suggester will have a height of 2.5 items. When this value is true, the height can be up to 5 items. */ isSuggestionPickerLarge: PropTypes.bool.isRequired, - /** Show that we should include ReportRecipientLocalTime view height */ - shouldIncludeReportRecipientLocalTimeHeight: PropTypes.bool.isRequired, - /** create accessibility label for each item */ accessibilityLabelExtractor: PropTypes.func.isRequired, diff --git a/src/components/AutoCompleteSuggestions/index.js b/src/components/AutoCompleteSuggestions/index.js index b37fcd7181d9..9234d04f4507 100644 --- a/src/components/AutoCompleteSuggestions/index.js +++ b/src/components/AutoCompleteSuggestions/index.js @@ -14,7 +14,7 @@ import useWindowDimensions from '../../hooks/useWindowDimensions'; * On the native platform, tapping on auto-complete suggestions will not blur the main input. */ -function AutoCompleteSuggestions({parentContainerRef, ...props}) { +function AutoCompleteSuggestions({measureParentContainer, ...props}) { const containerRef = React.useRef(null); const {windowHeight, windowWidth} = useWindowDimensions(); const [{width, left, bottom}, setContainerState] = React.useState({ @@ -37,11 +37,11 @@ function AutoCompleteSuggestions({parentContainerRef, ...props}) { }, []); React.useEffect(() => { - if (!parentContainerRef || !parentContainerRef.current) { + if (!measureParentContainer) { return; } - parentContainerRef.current.measureInWindow((x, y, w) => setContainerState({left: x, bottom: windowHeight - y, width: w})); - }, [parentContainerRef, windowHeight, windowWidth]); + measureParentContainer((x, y, w) => setContainerState({left: x, bottom: windowHeight - y, width: w})); + }, [measureParentContainer, windowHeight, windowWidth]); const componentToRender = ( ); - if (!width) { - return componentToRender; - } - - return ReactDOM.createPortal({componentToRender}, document.querySelector('body')); + return ( + Boolean(width) && + ReactDOM.createPortal({componentToRender}, document.querySelector('body')) + ); } AutoCompleteSuggestions.propTypes = propTypes; diff --git a/src/components/AutoCompleteSuggestions/index.native.js b/src/components/AutoCompleteSuggestions/index.native.js index 514cec6cd844..f5ff4636f395 100644 --- a/src/components/AutoCompleteSuggestions/index.native.js +++ b/src/components/AutoCompleteSuggestions/index.native.js @@ -1,10 +1,15 @@ import React from 'react'; +import {Portal} from '@gorhom/portal'; import BaseAutoCompleteSuggestions from './BaseAutoCompleteSuggestions'; import {propTypes} from './autoCompleteSuggestionsPropTypes'; -function AutoCompleteSuggestions({parentContainerRef, ...props}) { - // eslint-disable-next-line react/jsx-props-no-spreading - return ; +function AutoCompleteSuggestions({measureParentContainer, ...props}) { + return ( + + {/* eslint-disable-next-line react/jsx-props-no-spreading */} + + + ); } AutoCompleteSuggestions.propTypes = propTypes; diff --git a/src/components/Avatar.js b/src/components/Avatar.js index b96e60dd56d1..4f0eb60eb2e0 100644 --- a/src/components/Avatar.js +++ b/src/components/Avatar.js @@ -1,4 +1,4 @@ -import React, {useState} from 'react'; +import React, {useEffect, useState} from 'react'; import {View} from 'react-native'; import PropTypes from 'prop-types'; import _ from 'underscore'; @@ -40,7 +40,7 @@ const propTypes = { /** A fallback avatar icon to display when there is an error on loading avatar from remote URL. * If the avatar is type === workspace, this fallback icon will be ignored and decided based on the name prop. */ - fallbackIcon: PropTypes.func, + fallbackIcon: PropTypes.oneOfType([PropTypes.func, PropTypes.string]), /** Denotes whether it is an avatar or a workspace avatar */ type: PropTypes.oneOf([CONST.ICON_TYPE_AVATAR, CONST.ICON_TYPE_WORKSPACE]), @@ -66,6 +66,10 @@ function Avatar(props) { useNetwork({onReconnect: () => setImageError(false)}); + useEffect(() => { + setImageError(false); + }, [props.source]); + if (!props.source) { return null; } @@ -81,14 +85,14 @@ function Avatar(props) { const iconStyle = props.imageStyles && props.imageStyles.length ? [StyleUtils.getAvatarStyle(props.size), styles.bgTransparent, ...props.imageStyles] : undefined; const iconFillColor = isWorkspace ? StyleUtils.getDefaultWorkspaceAvatarColor(props.name).fill : props.fill; - const fallbackAvatar = isWorkspace ? ReportUtils.getDefaultWorkspaceAvatar(props.name) : props.fallbackIcon; + const fallbackAvatar = isWorkspace ? ReportUtils.getDefaultWorkspaceAvatar(props.name) : props.fallbackIcon || Expensicons.FallbackAvatar; return ( - {_.isFunction(props.source) || imageError ? ( + {_.isFunction(props.source) || (imageError && _.isFunction(fallbackAvatar)) ? ( setImageError(true)} /> diff --git a/src/components/AvatarWithDisplayName.js b/src/components/AvatarWithDisplayName.js index f1c87ee30316..81eace444de4 100644 --- a/src/components/AvatarWithDisplayName.js +++ b/src/components/AvatarWithDisplayName.js @@ -91,6 +91,7 @@ function AvatarWithDisplayName(props) { const shouldShowSubscriptAvatar = ReportUtils.shouldReportShowSubscript(props.report); const isExpenseRequest = ReportUtils.isExpenseRequest(props.report); const defaultSubscriptSize = isExpenseRequest ? CONST.AVATAR_SIZE.SMALL_NORMAL : props.size; + const avatarBorderColor = props.isAnonymous ? themeColors.highlightBG : themeColors.componentBG; return ( @@ -103,7 +104,7 @@ function AvatarWithDisplayName(props) { > {shouldShowSubscriptAvatar ? ( )} diff --git a/src/components/AvatarWithImagePicker.js b/src/components/AvatarWithImagePicker.js index 4cc70f00d1ae..a44d1841bbb6 100644 --- a/src/components/AvatarWithImagePicker.js +++ b/src/components/AvatarWithImagePicker.js @@ -61,7 +61,7 @@ const propTypes = { size: PropTypes.oneOf([CONST.AVATAR_SIZE.LARGE, CONST.AVATAR_SIZE.DEFAULT]), /** A fallback avatar icon to display when there is an error on loading avatar from remote URL. */ - fallbackIcon: PropTypes.func, + fallbackIcon: PropTypes.oneOfType([PropTypes.func, PropTypes.string]), /** Denotes whether it is an avatar or a workspace avatar */ type: PropTypes.oneOf([CONST.ICON_TYPE_AVATAR, CONST.ICON_TYPE_WORKSPACE]), @@ -296,6 +296,7 @@ class AvatarWithImagePicker extends React.Component { headerTitle={this.props.headerTitle} source={this.props.previewSource} originalFileName={this.props.originalFileName} + fallbackSource={this.props.fallbackIcon} > {({show}) => ( diff --git a/src/components/AvatarWithIndicator.js b/src/components/AvatarWithIndicator.js index b59f67a45b7b..5e7b8d1ee632 100644 --- a/src/components/AvatarWithIndicator.js +++ b/src/components/AvatarWithIndicator.js @@ -6,6 +6,7 @@ import styles from '../styles/styles'; import Tooltip from './Tooltip'; import * as UserUtils from '../libs/UserUtils'; import Indicator from './Indicator'; +import * as Expensicons from './Icon/Expensicons'; const propTypes = { /** URL for the avatar */ @@ -13,17 +14,24 @@ const propTypes = { /** To show a tooltip on hover */ tooltipText: PropTypes.string, + + /** A fallback avatar icon to display when there is an error on loading avatar from remote URL. */ + fallbackIcon: PropTypes.oneOfType([PropTypes.func, PropTypes.string]), }; const defaultProps = { tooltipText: '', + fallbackIcon: Expensicons.FallbackAvatar, }; function AvatarWithIndicator(props) { return ( - + diff --git a/src/components/Button/index.js b/src/components/Button/index.js index b28f84d372d5..4ca933a45d6f 100644 --- a/src/components/Button/index.js +++ b/src/components/Button/index.js @@ -306,6 +306,7 @@ class Button extends Component { ]} nativeID={this.props.nativeID} accessibilityLabel={this.props.accessibilityLabel} + accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON} hoverDimmingValue={1} > {this.renderContent()} diff --git a/src/components/CardPreview.js b/src/components/CardPreview.js new file mode 100644 index 000000000000..4f774d67360c --- /dev/null +++ b/src/components/CardPreview.js @@ -0,0 +1,68 @@ +import React from 'react'; +import {View} from 'react-native'; +import {withOnyx} from 'react-native-onyx'; +import PropTypes from 'prop-types'; +import styles from '../styles/styles'; +import Text from './Text'; +import usePrivatePersonalDetails from '../hooks/usePrivatePersonalDetails'; +import ONYXKEYS from '../ONYXKEYS'; +import ExpensifyCardImage from '../../assets/images/expensify-card.svg'; +import variables from '../styles/variables'; + +const propTypes = { + /** User's private personal details */ + privatePersonalDetails: PropTypes.shape({ + legalFirstName: PropTypes.string, + legalLastName: PropTypes.string, + }), + /** Session info for the currently logged in user. */ + session: PropTypes.shape({ + /** Currently logged-in user email */ + email: PropTypes.string, + }), +}; + +const defaultProps = { + privatePersonalDetails: { + legalFirstName: '', + legalLastName: '', + }, + session: { + email: '', + }, +}; + +function CardPreview({privatePersonalDetails: {legalFirstName, legalLastName}, session: {email}}) { + usePrivatePersonalDetails(); + const cardHolder = legalFirstName && legalLastName ? `${legalFirstName} ${legalLastName}` : email; + + return ( + + + + {cardHolder} + + + ); +} + +CardPreview.propTypes = propTypes; +CardPreview.defaultProps = defaultProps; +CardPreview.displayName = 'CardPreview'; + +export default withOnyx({ + privatePersonalDetails: { + key: ONYXKEYS.PRIVATE_PERSONAL_DETAILS, + }, + session: { + key: ONYXKEYS.SESSION, + }, +})(CardPreview); diff --git a/src/components/CountryPicker/CountrySelectorModal.js b/src/components/CountryPicker/CountrySelectorModal.js deleted file mode 100644 index 6c6cd19af0c7..000000000000 --- a/src/components/CountryPicker/CountrySelectorModal.js +++ /dev/null @@ -1,105 +0,0 @@ -import _ from 'underscore'; -import React, {useMemo, useEffect} from 'react'; -import PropTypes from 'prop-types'; -import CONST from '../../CONST'; -import useLocalize from '../../hooks/useLocalize'; -import HeaderWithBackButton from '../HeaderWithBackButton'; -import SelectionList from '../SelectionList'; -import Modal from '../Modal'; -import ScreenWrapper from '../ScreenWrapper'; -import styles from '../../styles/styles'; -import searchCountryOptions from '../../libs/searchCountryOptions'; -import StringUtils from '../../libs/StringUtils'; - -const propTypes = { - /** Whether the modal is visible */ - isVisible: PropTypes.bool.isRequired, - - /** Country value selected */ - currentCountry: PropTypes.string, - - /** Function to call when the user selects a Country */ - onCountrySelected: PropTypes.func, - - /** Function to call when the user closes the Country modal */ - onClose: PropTypes.func, - - /** The search value from the selection list */ - searchValue: PropTypes.string.isRequired, - - /** Function to call when the user types in the search input */ - setSearchValue: PropTypes.func.isRequired, -}; - -const defaultProps = { - currentCountry: '', - onClose: () => {}, - onCountrySelected: () => {}, -}; - -function CountrySelectorModal({currentCountry, isVisible, onClose, onCountrySelected, setSearchValue, searchValue}) { - const {translate} = useLocalize(); - - useEffect(() => { - if (isVisible) { - return; - } - setSearchValue(''); - }, [isVisible, setSearchValue]); - - const countries = useMemo( - () => - _.map(_.keys(CONST.ALL_COUNTRIES), (countryISO) => { - const countryName = translate(`allCountries.${countryISO}`); - return { - value: countryISO, - keyForList: countryISO, - text: countryName, - isSelected: currentCountry === countryISO, - searchValue: StringUtils.sanitizeString(`${countryISO}${countryName}`), - }; - }), - [translate, currentCountry], - ); - - const searchResults = searchCountryOptions(searchValue, countries); - const headerMessage = searchValue.trim() && !searchResults.length ? translate('common.noResultsFound') : ''; - - return ( - - - - - - - ); -} - -CountrySelectorModal.propTypes = propTypes; -CountrySelectorModal.defaultProps = defaultProps; -CountrySelectorModal.displayName = 'CountrySelectorModal'; - -export default CountrySelectorModal; diff --git a/src/components/CountryPicker/index.js b/src/components/CountryPicker/index.js deleted file mode 100644 index c838ed8f4060..000000000000 --- a/src/components/CountryPicker/index.js +++ /dev/null @@ -1,88 +0,0 @@ -import React, {useState} from 'react'; -import {View} from 'react-native'; -import PropTypes from 'prop-types'; -import styles from '../../styles/styles'; -import MenuItemWithTopDescription from '../MenuItemWithTopDescription'; -import useLocalize from '../../hooks/useLocalize'; -import CountrySelectorModal from './CountrySelectorModal'; -import FormHelpMessage from '../FormHelpMessage'; -import refPropTypes from '../refPropTypes'; - -const propTypes = { - /** Form Error description */ - errorText: PropTypes.string, - - /** Country to display */ - value: PropTypes.string, - - /** Callback to call when the input changes */ - onInputChange: PropTypes.func, - - /** A ref to forward to MenuItemWithTopDescription */ - forwardedRef: refPropTypes, -}; - -const defaultProps = { - value: undefined, - forwardedRef: undefined, - errorText: '', - onInputChange: () => {}, -}; - -function CountryPicker({value, errorText, onInputChange, forwardedRef}) { - const {translate} = useLocalize(); - const [isPickerVisible, setIsPickerVisible] = useState(false); - const [searchValue, setSearchValue] = useState(''); - - const showPickerModal = () => { - setIsPickerVisible(true); - }; - - const hidePickerModal = () => { - setIsPickerVisible(false); - }; - - const updateCountryInput = (country) => { - onInputChange(country.value); - hidePickerModal(); - }; - - const title = value ? translate(`allCountries.${value}`) : ''; - const descStyle = title.length === 0 ? styles.textNormal : null; - - return ( - - - - - - - - ); -} - -CountryPicker.propTypes = propTypes; -CountryPicker.defaultProps = defaultProps; -CountryPicker.displayName = 'CountryPicker'; - -export default React.forwardRef((props, ref) => ( - -)); diff --git a/src/components/CountrySelector.js b/src/components/CountrySelector.js new file mode 100644 index 000000000000..2788f3cea8e3 --- /dev/null +++ b/src/components/CountrySelector.js @@ -0,0 +1,77 @@ +import React, {useEffect} from 'react'; +import PropTypes from 'prop-types'; +import {View} from 'react-native'; +import styles from '../styles/styles'; +import Navigation from '../libs/Navigation/Navigation'; +import ROUTES from '../ROUTES'; +import useLocalize from '../hooks/useLocalize'; +import MenuItemWithTopDescription from './MenuItemWithTopDescription'; +import FormHelpMessage from './FormHelpMessage'; + +const propTypes = { + /** Form error text. e.g when no country is selected */ + errorText: PropTypes.string, + + /** Callback called when the country changes. */ + onInputChange: PropTypes.func.isRequired, + + /** Current selected country */ + value: PropTypes.string, + + /** inputID used by the Form component */ + // eslint-disable-next-line react/no-unused-prop-types + inputID: PropTypes.string.isRequired, + + /** React ref being forwarded to the MenuItemWithTopDescription */ + forwardedRef: PropTypes.func, +}; + +const defaultProps = { + errorText: '', + value: undefined, + forwardedRef: () => {}, +}; + +function CountrySelector({errorText, value: countryCode, onInputChange, forwardedRef}) { + const {translate} = useLocalize(); + + const title = countryCode ? translate(`allCountries.${countryCode}`) : ''; + const countryTitleDescStyle = title.length === 0 ? styles.textNormal : null; + + useEffect(() => { + // This will cause the form to revalidate and remove any error related to country name + onInputChange(countryCode); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [countryCode]); + + return ( + + { + const activeRoute = Navigation.getActiveRoute().replace(/\?.*/, ''); + Navigation.navigate(ROUTES.SETTINGS_PERSONAL_DETAILS_ADDRESS_COUNTRY.getRoute(countryCode, activeRoute)); + }} + /> + + + + + ); +} + +CountrySelector.propTypes = propTypes; +CountrySelector.defaultProps = defaultProps; +CountrySelector.displayName = 'CountrySelector'; + +export default React.forwardRef((props, ref) => ( + +)); diff --git a/src/components/DistanceRequest.js b/src/components/DistanceRequest.js index 5e9b73f2eb3a..aa7878337f21 100644 --- a/src/components/DistanceRequest.js +++ b/src/components/DistanceRequest.js @@ -5,39 +5,31 @@ import lodashGet from 'lodash/get'; import lodashIsNil from 'lodash/isNil'; import PropTypes from 'prop-types'; import _ from 'underscore'; - import CONST from '../CONST'; import ROUTES from '../ROUTES'; import ONYXKEYS from '../ONYXKEYS'; - import styles from '../styles/styles'; import variables from '../styles/variables'; -import theme from '../styles/themes/default'; - -import transactionPropTypes from './transactionPropTypes'; - +import LinearGradient from './LinearGradient'; +import * as MapboxToken from '../libs/actions/MapboxToken'; import useNetwork from '../hooks/useNetwork'; -import usePrevious from '../hooks/usePrevious'; import useLocalize from '../hooks/useLocalize'; - -import * as ErrorUtils from '../libs/ErrorUtils'; import Navigation from '../libs/Navigation/Navigation'; -import * as MapboxToken from '../libs/actions/MapboxToken'; +import reportPropTypes from '../pages/reportPropTypes'; +import DotIndicatorMessage from './DotIndicatorMessage'; +import * as ErrorUtils from '../libs/ErrorUtils'; +import usePrevious from '../hooks/usePrevious'; +import theme from '../styles/themes/default'; import * as Transaction from '../libs/actions/Transaction'; import * as TransactionUtils from '../libs/TransactionUtils'; import * as IOUUtils from '../libs/IOUUtils'; - import Button from './Button'; import DistanceMapView from './DistanceMapView'; -import LinearGradient from './LinearGradient'; import * as Expensicons from './Icon/Expensicons'; import PendingMapView from './MapView/PendingMapView'; -import DotIndicatorMessage from './DotIndicatorMessage'; import MenuItemWithTopDescription from './MenuItemWithTopDescription'; -import {iouPropTypes} from '../pages/iou/propTypes'; -import reportPropTypes from '../pages/reportPropTypes'; -import * as IOU from '../libs/actions/IOU'; import * as StyleUtils from '../styles/StyleUtils'; +import transactionPropTypes from './transactionPropTypes'; import ScreenWrapper from './ScreenWrapper'; import FullPageNotFoundView from './BlockingViews/FullPageNotFoundView'; import HeaderWithBackButton from './HeaderWithBackButton'; @@ -46,18 +38,12 @@ const MAX_WAYPOINTS = 25; const MAX_WAYPOINTS_TO_DISPLAY = 4; const propTypes = { - /** Holds data related to Money Request view state, rather than the underlying Money Request data. */ - iou: iouPropTypes, - - /** Type of money request (i.e. IOU) */ - iouType: PropTypes.oneOf(_.values(CONST.IOU.MONEY_REQUEST_TYPE)), + /** The transactionID of this request */ + transactionID: PropTypes.string, /** The report to which the distance request is associated */ report: reportPropTypes, - /** The optimistic transaction for this request */ - transaction: transactionPropTypes, - /** Data about Mapbox token for calling Mapbox API */ mapboxAccessToken: PropTypes.shape({ /** Temporary token for Mapbox API */ @@ -67,6 +53,15 @@ const propTypes = { expiration: PropTypes.string, }), + /** Are we editing an existing distance request, or creating a new one? */ + isEditingRequest: PropTypes.bool, + + /** Called on submit of this page */ + onSubmit: PropTypes.func.isRequired, + + /* Onyx Props */ + transaction: transactionPropTypes, + /** React Navigation route */ route: PropTypes.shape({ /** Params from the route */ @@ -81,16 +76,16 @@ const propTypes = { }; const defaultProps = { - iou: {}, - iouType: '', + transactionID: '', report: {}, - transaction: {}, + isEditingRequest: false, mapboxAccessToken: { token: '', }, + transaction: {}, }; -function DistanceRequest({iou, iouType, report, transaction, mapboxAccessToken, route}) { +function DistanceRequest({transactionID, report, transaction, mapboxAccessToken, route, isEditingRequest, onSubmit}) { const [shouldShowGradient, setShouldShowGradient] = useState(false); const [scrollContainerHeight, setScrollContainerHeight] = useState(0); const [scrollContentHeight, setScrollContentHeight] = useState(0); @@ -99,6 +94,7 @@ function DistanceRequest({iou, iouType, report, transaction, mapboxAccessToken, const isEditing = lodashGet(route, 'path', '').includes('address'); const reportID = lodashGet(report, 'reportID', ''); + const iouType = lodashGet(route, 'params.iouType', ''); const waypoints = useMemo(() => lodashGet(transaction, 'comment.waypoints', {}), [transaction]); const previousWaypoints = usePrevious(waypoints); const numberOfWaypoints = _.size(waypoints); @@ -107,6 +103,7 @@ function DistanceRequest({iou, iouType, report, transaction, mapboxAccessToken, const lastWaypointIndex = numberOfWaypoints - 1; const isLoadingRoute = lodashGet(transaction, 'comment.isLoading', false); + const isLoading = lodashGet(transaction, 'isLoading', false); const hasRouteError = !!lodashGet(transaction, 'errorFields.route'); const hasRoute = TransactionUtils.hasRoute(transaction); const validatedWaypoints = TransactionUtils.getValidWaypoints(waypoints); @@ -159,12 +156,12 @@ function DistanceRequest({iou, iouType, report, transaction, mapboxAccessToken, }, []); useEffect(() => { - if (!iou.transactionID || !_.isEmpty(waypoints)) { + if (!transactionID || !_.isEmpty(waypoints)) { return; } // Create the initial start and stop waypoints - Transaction.createInitialWaypoints(iou.transactionID); - }, [iou.transactionID, waypoints]); + Transaction.createInitialWaypoints(transactionID); + }, [transactionID, waypoints]); const updateGradientVisibility = (event = {}) => { // If a waypoint extends past the bottom of the visible area show the gradient, else hide it. @@ -176,8 +173,8 @@ function DistanceRequest({iou, iouType, report, transaction, mapboxAccessToken, return; } - Transaction.getRoute(iou.transactionID, validatedWaypoints); - }, [shouldFetchRoute, iou.transactionID, validatedWaypoints, isOffline]); + Transaction.getRoute(transactionID, validatedWaypoints); + }, [shouldFetchRoute, transactionID, validatedWaypoints, isOffline]); useEffect(() => { if (numberOfWaypoints <= numberOfPreviousWaypoints) { @@ -192,13 +189,12 @@ function DistanceRequest({iou, iouType, report, transaction, mapboxAccessToken, Navigation.goBack(isEditing ? ROUTES.MONEY_REQUEST_CONFIRMATION.getRoute(iouType, reportID) : ROUTES.HOME); }; - const navigateToNextPage = () => { - if (isEditing) { - Navigation.goBack(ROUTES.MONEY_REQUEST_CONFIRMATION.getRoute(iouType, reportID)); - return; - } - - IOU.navigateToNextPage(iou, iouType, reportID, report); + /** + * Takes the user to the page for editing a specific waypoint + * @param {Number} index of the waypoint to edit + */ + const navigateToWaypointEditPage = (index) => { + Navigation.navigate(isEditingRequest ? ROUTES.MONEY_REQUEST_EDIT_WAYPOINT.getRoute(report.reportID, transactionID, index) : ROUTES.MONEY_REQUEST_WAYPOINT.getRoute('request', index)); }; const content = ( @@ -237,7 +233,7 @@ function DistanceRequest({iou, iouType, report, transaction, mapboxAccessToken, secondaryIcon={waypointIcon} secondaryIconFill={theme.icon} shouldShowRightIcon - onPress={() => Navigation.navigate(ROUTES.MONEY_REQUEST_WAYPOINT.getRoute('request', index))} + onPress={() => navigateToWaypointEditPage(index)} key={key} /> ); @@ -261,10 +257,7 @@ function DistanceRequest({iou, iouType, report, transaction, mapboxAccessToken, {!isFiltered && ( diff --git a/src/components/EmojiSuggestions.js b/src/components/EmojiSuggestions.js index b06b0cc63eb8..d7f7a8d6091a 100644 --- a/src/components/EmojiSuggestions.js +++ b/src/components/EmojiSuggestions.js @@ -40,9 +40,6 @@ const propTypes = { * 2.5 items. When this value is true, the height can be up to 5 items. */ isEmojiPickerLarge: PropTypes.bool.isRequired, - /** Show that we should include ReportRecipientLocalTime view height */ - shouldIncludeReportRecipientLocalTimeHeight: PropTypes.bool.isRequired, - /** Stores user's preferred skin tone */ preferredSkinToneIndex: PropTypes.number.isRequired, @@ -102,7 +99,6 @@ function EmojiSuggestions(props) { highlightedSuggestionIndex={props.highlightedEmojiIndex} onSelect={props.onSelect} isSuggestionPickerLarge={props.isEmojiPickerLarge} - shouldIncludeReportRecipientLocalTimeHeight={props.shouldIncludeReportRecipientLocalTimeHeight} accessibilityLabelExtractor={keyExtractor} measureParentContainer={props.measureParentContainer} /> diff --git a/src/components/ExceededCommentLength.js b/src/components/ExceededCommentLength.js index 7c9ec4d2db25..9c785cec0395 100644 --- a/src/components/ExceededCommentLength.js +++ b/src/components/ExceededCommentLength.js @@ -63,5 +63,6 @@ ExceededCommentLength.displayName = 'ExceededCommentLength'; export default withOnyx({ comment: { key: ({reportID}) => `${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${reportID}`, + initialValue: '', }, })(ExceededCommentLength); diff --git a/src/components/Form/FormProvider.js b/src/components/Form/FormProvider.js index 408f8c2c2b7f..90f5c22e5b3c 100644 --- a/src/components/Form/FormProvider.js +++ b/src/components/Form/FormProvider.js @@ -239,6 +239,7 @@ function FormProvider({validate, shouldValidateOnBlur, shouldValidateOnChange, c onSubmit={submit} inputRefs={inputRefs} errors={errors} + enabledWhenOffline={enabledWhenOffline} > {children} diff --git a/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.js b/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.js index 4b61d55ae228..bba62cc4f4e0 100755 --- a/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.js +++ b/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.js @@ -58,7 +58,7 @@ const defaultViewProps = {style: [styles.alignItemsStart, styles.userSelectText] // costly invalidations and commits. function BaseHTMLEngineProvider(props) { // We need to memoize this prop to make it referentially stable. - const defaultTextProps = useMemo(() => ({selectable: props.textSelectable, allowFontScaling: false}), [props.textSelectable]); + const defaultTextProps = useMemo(() => ({selectable: props.textSelectable, allowFontScaling: false, textBreakStrategy: 'simple'}), [props.textSelectable]); // We need to pass multiple system-specific fonts for emojis but // we can't apply multiple fonts at once so we need to pass fallback fonts. diff --git a/src/components/ImageView/index.js b/src/components/ImageView/index.js index e0dce180043b..1d90a5723016 100644 --- a/src/components/ImageView/index.js +++ b/src/components/ImageView/index.js @@ -22,13 +22,16 @@ const propTypes = { /** image file name */ fileName: PropTypes.string.isRequired, + + onError: PropTypes.func, }; const defaultProps = { isAuthTokenRequired: false, + onError: () => {}, }; -function ImageView({isAuthTokenRequired, url, fileName}) { +function ImageView({isAuthTokenRequired, url, fileName, onError}) { const [isLoading, setIsLoading] = useState(true); const [containerHeight, setContainerHeight] = useState(0); const [containerWidth, setContainerWidth] = useState(0); @@ -238,6 +241,7 @@ function ImageView({isAuthTokenRequired, url, fileName}) { resizeMode={zoomScale > 1 ? Image.resizeMode.center : Image.resizeMode.contain} onLoadStart={imageLoadingStart} onLoad={imageLoad} + onError={onError} /> {(isLoading || zoomScale === 0) && } @@ -268,6 +272,7 @@ function ImageView({isAuthTokenRequired, url, fileName}) { resizeMode={Image.resizeMode.contain} onLoadStart={imageLoadingStart} onLoad={imageLoad} + onError={onError} /> diff --git a/src/components/LHNOptionsList/OptionRowLHN.js b/src/components/LHNOptionsList/OptionRowLHN.js index f5a293701454..3cfd7c4c4138 100644 --- a/src/components/LHNOptionsList/OptionRowLHN.js +++ b/src/components/LHNOptionsList/OptionRowLHN.js @@ -85,8 +85,8 @@ function OptionRowLHN(props) { const displayNameStyle = StyleUtils.combineStyles([styles.optionDisplayName, styles.optionDisplayNameCompact, styles.pre, ...textUnreadStyle], props.style); const alternateTextStyle = StyleUtils.combineStyles( props.viewMode === CONST.OPTION_MODE.COMPACT - ? [textStyle, styles.optionAlternateText, styles.noWrap, styles.textLabelSupporting, styles.optionAlternateTextCompact, styles.ml2] - : [textStyle, styles.optionAlternateText, styles.noWrap, styles.textLabelSupporting], + ? [textStyle, styles.optionAlternateText, styles.pre, styles.textLabelSupporting, styles.optionAlternateTextCompact, styles.ml2] + : [textStyle, styles.optionAlternateText, styles.pre, styles.textLabelSupporting], props.style, ); const contentContainerStyles = @@ -102,10 +102,7 @@ function OptionRowLHN(props) { const hasBrickError = optionItem.brickRoadIndicator === CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR; const defaultSubscriptSize = optionItem.isExpenseRequest ? CONST.AVATAR_SIZE.SMALL_NORMAL : CONST.AVATAR_SIZE.DEFAULT; const shouldShowGreenDotIndicator = - !hasBrickError && - (optionItem.isUnreadWithMention || - ReportUtils.isWaitingForIOUActionFromCurrentUser(optionItem) || - (optionItem.isTaskReport && optionItem.isTaskAssignee && !optionItem.isCompletedTaskReport && !optionItem.isArchivedRoom)); + !hasBrickError && (optionItem.isUnreadWithMention || optionItem.isWaitingForTaskCompleteFromAssignee || ReportUtils.isWaitingForIOUActionFromCurrentUser(optionItem)); /** * Show the ReportActionContextMenu modal popover. @@ -180,6 +177,7 @@ function OptionRowLHN(props) { ]} accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON} accessibilityLabel={translate('accessibilityHints.navigatesToChat')} + needsOffscreenAlphaCompositing={props.optionItem.icons.length >= 2} > diff --git a/src/components/LHNOptionsList/OptionRowLHNData.js b/src/components/LHNOptionsList/OptionRowLHNData.js index 4eebdd387fab..87358f05b9c9 100644 --- a/src/components/LHNOptionsList/OptionRowLHNData.js +++ b/src/components/LHNOptionsList/OptionRowLHNData.js @@ -106,7 +106,7 @@ function OptionRowLHNData({ const optionItem = useMemo(() => { // Note: ideally we'd have this as a dependent selector in onyx! - const item = SidebarUtils.getOptionData(fullReport, reportActions, personalDetails, preferredLocale, policy); + const item = SidebarUtils.getOptionData(fullReport, reportActions, personalDetails, preferredLocale, policy, parentReportAction); if (deepEqual(item, optionItemRef.current)) { return optionItemRef.current; } @@ -156,6 +156,7 @@ const personalDetailsSelector = (personalDetails) => firstName: personalData.firstName, status: personalData.status, avatar: UserUtils.getAvatar(personalData.avatar, personalData.accountID), + fallbackIcon: personalData.fallbackIcon, }; return finalPersonalDetails; }, diff --git a/src/components/MagicCodeInput.js b/src/components/MagicCodeInput.js index 454aacc8a03b..1db1acddc5d7 100644 --- a/src/components/MagicCodeInput.js +++ b/src/components/MagicCodeInput.js @@ -2,6 +2,7 @@ import React, {useEffect, useImperativeHandle, useRef, useState, forwardRef} fro import {StyleSheet, View} from 'react-native'; import PropTypes from 'prop-types'; import _ from 'underscore'; +import {TapGestureHandler} from 'react-native-gesture-handler'; import styles from '../styles/styles'; import * as StyleUtils from '../styles/StyleUtils'; import * as ValidationUtils from '../libs/ValidationUtils'; @@ -12,6 +13,9 @@ import FormHelpMessage from './FormHelpMessage'; import {withNetwork} from './OnyxProvider'; import networkPropTypes from './networkPropTypes'; import useNetwork from '../hooks/useNetwork'; +import * as Browser from '../libs/Browser'; + +const TEXT_INPUT_EMPTY_STATE = ''; const propTypes = { /** Information about the network */ @@ -91,22 +95,40 @@ const composeToString = (value) => _.map(value, (v) => (v === undefined || v === const getInputPlaceholderSlots = (length) => Array.from(Array(length).keys()); function MagicCodeInput(props) { - const inputRefs = useRef([]); - const [input, setInput] = useState(''); + const inputRefs = useRef(); + const [input, setInput] = useState(TEXT_INPUT_EMPTY_STATE); const [focusedIndex, setFocusedIndex] = useState(0); const [editIndex, setEditIndex] = useState(0); + const shouldFocusLast = useRef(false); + const inputWidth = useRef(0); + const lastFocusedIndex = useRef(0); const blurMagicCodeInput = () => { - inputRefs.current[editIndex].blur(); + inputRefs.current.blur(); setFocusedIndex(undefined); }; + const focusMagicCodeInput = () => { + setFocusedIndex(0); + lastFocusedIndex.current = 0; + setEditIndex(0); + inputRefs.current.focus(); + }; + useImperativeHandle(props.innerRef, () => ({ focus() { - inputRefs.current[0].focus(); + focusMagicCodeInput(); + }, + resetFocus() { + setInput(TEXT_INPUT_EMPTY_STATE); + focusMagicCodeInput(); }, clear() { - inputRefs.current[0].focus(); + setInput(TEXT_INPUT_EMPTY_STATE); + setFocusedIndex(0); + lastFocusedIndex.current = 0; + setEditIndex(0); + inputRefs.current.focus(); props.onChangeText(''); }, blur() { @@ -137,17 +159,37 @@ function MagicCodeInput(props) { }, [props.value, props.shouldSubmitOnComplete]); /** - * Callback for the onFocus event, updates the indexes - * of the currently focused input. + * Focuses on the input when it is pressed. * * @param {Object} event * @param {Number} index */ - const onFocus = (event, index) => { + const onFocus = (event) => { + if (shouldFocusLast.current) { + setInput(TEXT_INPUT_EMPTY_STATE); + setFocusedIndex(lastFocusedIndex.current); + setEditIndex(lastFocusedIndex.current); + } event.preventDefault(); - setInput(''); + }; + + /** + * Callback for the onPress event, updates the indexes + * of the currently focused input. + * + * @param {Number} index + */ + const onPress = (index) => { + shouldFocusLast.current = false; + // TapGestureHandler works differently on mobile web and native app + // On web gesture handler doesn't block interactions with textInput below so there is no need to run `focus()` manually + if (!Browser.isMobileChrome() && !Browser.isMobileSafari()) { + inputRefs.current.focus(); + } + setInput(TEXT_INPUT_EMPTY_STATE); setFocusedIndex(index); setEditIndex(index); + lastFocusedIndex.current = index; }; /** @@ -175,7 +217,9 @@ function MagicCodeInput(props) { let numbers = decomposeString(props.value, props.maxLength); numbers = [...numbers.slice(0, editIndex), ...numbersArr, ...numbers.slice(numbersArr.length + editIndex, props.maxLength)]; - inputRefs.current[updatedFocusedIndex].focus(); + setFocusedIndex(updatedFocusedIndex); + setEditIndex(updatedFocusedIndex); + setInput(TEXT_INPUT_EMPTY_STATE); const finalInput = composeToString(numbers); props.onChangeText(finalInput); @@ -196,7 +240,7 @@ function MagicCodeInput(props) { // If the currently focused index already has a value, it will delete // that value but maintain the focus on the same input. if (numbers[focusedIndex] !== CONST.MAGIC_CODE_EMPTY_CHAR) { - setInput(''); + setInput(TEXT_INPUT_EMPTY_STATE); numbers = [...numbers.slice(0, focusedIndex), CONST.MAGIC_CODE_EMPTY_CHAR, ...numbers.slice(focusedIndex + 1, props.maxLength)]; setEditIndex(focusedIndex); props.onChangeText(composeToString(numbers)); @@ -215,24 +259,37 @@ function MagicCodeInput(props) { } const newFocusedIndex = Math.max(0, focusedIndex - 1); + + // Saves the input string so that it can compare to the change text + // event that will be triggered, this is a workaround for mobile that + // triggers the change text on the event after the key press. + setInput(TEXT_INPUT_EMPTY_STATE); + setFocusedIndex(newFocusedIndex); + setEditIndex(newFocusedIndex); props.onChangeText(composeToString(numbers)); if (!_.isUndefined(newFocusedIndex)) { - inputRefs.current[newFocusedIndex].focus(); + inputRefs.current.focus(); } } if (keyValue === 'ArrowLeft' && !_.isUndefined(focusedIndex)) { const newFocusedIndex = Math.max(0, focusedIndex - 1); - inputRefs.current[newFocusedIndex].focus(); + setInput(TEXT_INPUT_EMPTY_STATE); + setFocusedIndex(newFocusedIndex); + setEditIndex(newFocusedIndex); + inputRefs.current.focus(); } else if (keyValue === 'ArrowRight' && !_.isUndefined(focusedIndex)) { const newFocusedIndex = Math.min(focusedIndex + 1, props.maxLength - 1); - inputRefs.current[newFocusedIndex].focus(); + setInput(TEXT_INPUT_EMPTY_STATE); + setFocusedIndex(newFocusedIndex); + setEditIndex(newFocusedIndex); + inputRefs.current.focus(); } else if (keyValue === 'Enter') { // We should prevent users from submitting when it's offline. if (props.network.isOffline) { return; } - setInput(''); + setInput(TEXT_INPUT_EMPTY_STATE); props.onFulfill(props.value); } }; @@ -240,6 +297,48 @@ function MagicCodeInput(props) { return ( <> + { + onPress(Math.floor(e.nativeEvent.x / (inputWidth.current / props.maxLength))); + }} + > + {/* Android does not handle touch on invisible Views so I created a wrapper around invisible TextInput just to handle taps */} + + { + inputWidth.current = e.nativeEvent.layout.width; + }} + ref={(ref) => (inputRefs.current = ref)} + autoFocus={props.autoFocus} + inputMode="numeric" + textContentType="oneTimeCode" + name={props.name} + maxLength={props.maxLength} + value={input} + hideFocusedState + autoComplete={props.autoComplete} + keyboardType={CONST.KEYBOARD_TYPE.NUMBER_PAD} + onChangeText={(value) => { + onChangeText(value); + }} + onKeyPress={onKeyPress} + onFocus={onFocus} + onBlur={() => { + shouldFocusLast.current = true; + lastFocusedIndex.current = focusedIndex; + setFocusedIndex(undefined); + }} + selectionColor="transparent" + inputStyle={[styles.inputTransparent]} + accessibilityRole={CONST.ACCESSIBILITY_ROLE.TEXT} + style={[styles.inputTransparent]} + textInputContainerStyles={[styles.borderNone]} + /> + + {_.map(getInputPlaceholderSlots(props.maxLength), (index) => ( {decomposeString(props.value, props.maxLength)[index] || ''} - {/* Hide the input above the text. Cannot set opacity to 0 as it would break pasting on iOS Safari. */} - - { - inputRefs.current[index] = ref; - // Setting attribute type to "search" to prevent Password Manager from appearing in Mobile Chrome - if (ref && ref.setAttribute) { - ref.setAttribute('type', 'search'); - } - }} - autoFocus={index === 0 && props.autoFocus} - inputMode="numeric" - textContentType="oneTimeCode" - name={props.name} - maxLength={props.maxLength} - value={input} - hideFocusedState - autoComplete={index === 0 ? props.autoComplete : 'off'} - keyboardType={CONST.KEYBOARD_TYPE.NUMBER_PAD} - onChangeText={(value) => { - // Do not run when the event comes from an input that is - // not currently being responsible for the input, this is - // necessary to avoid calls when the input changes due to - // deleted characters. Only happens in mobile. - if (index !== editIndex || _.isUndefined(focusedIndex)) { - return; - } - onChangeText(value); - }} - onKeyPress={onKeyPress} - onFocus={(event) => onFocus(event, index)} - // Manually set selectionColor to make caret transparent. - // We cannot use caretHidden as it breaks the pasting function on Android. - selectionColor="transparent" - textInputContainerStyles={[styles.borderNone]} - inputStyle={[styles.inputTransparent]} - accessibilityRole={CONST.ACCESSIBILITY_ROLE.TEXT} - /> - ))} diff --git a/src/components/MapView/MapView.tsx b/src/components/MapView/MapView.tsx index d9f51e111a43..9370cc95fb82 100644 --- a/src/components/MapView/MapView.tsx +++ b/src/components/MapView/MapView.tsx @@ -71,6 +71,7 @@ const MapView = forwardRef(({accessToken, style, ma onMapIdle={setMapIdle} pitchEnabled={pitchEnabled} attributionPosition={{...styles.r2, ...styles.b2}} + scaleBarEnabled={false} logoPosition={{...styles.l2, ...styles.b2}} // eslint-disable-next-line {...responder.panHandlers} diff --git a/src/components/MapView/MapView.web.tsx b/src/components/MapView/MapView.web.tsx index ce13cee10f54..52f953e0d3bd 100644 --- a/src/components/MapView/MapView.web.tsx +++ b/src/components/MapView/MapView.web.tsx @@ -6,11 +6,14 @@ import React, {forwardRef, useCallback, useEffect, useImperativeHandle, useState} from 'react'; import {View} from 'react-native'; import Map, {MapRef, Marker} from 'react-map-gl'; +import mapboxgl from 'mapbox-gl'; import responder from './responder'; import utils from './utils'; import CONST from '../../CONST'; +import * as StyleUtils from '../../styles/StyleUtils'; +import themeColors from '../../styles/themes/default'; import Direction from './Direction'; import {MapViewHandle, MapViewProps} from './MapViewTypes'; @@ -81,12 +84,14 @@ const MapView = forwardRef( > {waypoints?.map(({coordinate, markerComponent, id}) => { diff --git a/src/components/MentionSuggestions.js b/src/components/MentionSuggestions.js index 11df8a597ded..6c0803ca9d64 100644 --- a/src/components/MentionSuggestions.js +++ b/src/components/MentionSuggestions.js @@ -41,9 +41,6 @@ const propTypes = { * When this value is false, the suggester will have a height of 2.5 items. When this value is true, the height can be up to 5 items. */ isMentionPickerLarge: PropTypes.bool.isRequired, - /** Show that we should include ReportRecipientLocalTime view height */ - shouldIncludeReportRecipientLocalTimeHeight: PropTypes.bool.isRequired, - /** Meaures the parent container's position and dimensions. */ measureParentContainer: PropTypes.func, }; @@ -81,6 +78,7 @@ function MentionSuggestions(props) { name={item.icons[0].name} type={item.icons[0].type} fill={themeColors.success} + fallbackIcon={item.icons[0].fallbackIcon} /> diff --git a/src/components/MenuItem.js b/src/components/MenuItem.js index 268351699567..11f7d547962b 100644 --- a/src/components/MenuItem.js +++ b/src/components/MenuItem.js @@ -56,7 +56,6 @@ const defaultProps = { disabled: false, isSelected: false, subtitle: undefined, - subtitleTextStyle: {}, iconType: CONST.ICON_TYPE_ICON, onPress: () => {}, onSecondaryInteraction: undefined, @@ -76,6 +75,7 @@ const defaultProps = { title: '', numberOfLinesTitle: 1, shouldGreyOutWhenDisabled: true, + error: '', shouldRenderAsHTML: false, }; @@ -276,6 +276,11 @@ const MenuItem = React.forwardRef((props, ref) => { {props.description} )} + {Boolean(props.error) && ( + + {props.error} + + )} {Boolean(props.furtherDetails) && ( { {/* Since subtitle can be of type number, we should allow 0 to be shown */} {(props.subtitle || props.subtitle === 0) && ( - {props.subtitle} + {props.subtitle} )} {!_.isEmpty(props.floatRightAvatars) && ( diff --git a/src/components/MoneyRequestConfirmationList.js b/src/components/MoneyRequestConfirmationList.js index 6a71916588d9..695d935d7183 100755 --- a/src/components/MoneyRequestConfirmationList.js +++ b/src/components/MoneyRequestConfirmationList.js @@ -38,6 +38,7 @@ import transactionPropTypes from './transactionPropTypes'; import DistanceRequestUtils from '../libs/DistanceRequestUtils'; import * as IOU from '../libs/actions/IOU'; import * as TransactionUtils from '../libs/TransactionUtils'; +import * as PolicyUtils from '../libs/PolicyUtils'; const propTypes = { /** Callback to inform parent modal of success */ @@ -142,13 +143,7 @@ const propTypes = { policyCategories: PropTypes.objectOf(categoryPropTypes), /** Collection of tags attached to a policy */ - policyTags: PropTypes.objectOf( - PropTypes.shape({ - name: PropTypes.string, - required: PropTypes.bool, - tags: PropTypes.objectOf(tagPropTypes), - }), - ), + policyTags: tagPropTypes, }; const defaultProps = { @@ -202,12 +197,12 @@ function MoneyRequestConfirmationList(props) { const shouldShowCategories = isPolicyExpenseChat && Permissions.canUseCategories(props.betas) && OptionsListUtils.hasEnabledOptions(_.values(props.policyCategories)); // Fetches the first tag list of the policy - const tagListKey = _.first(_.keys(props.policyTags)); - const tagList = lodashGet(props.policyTags, [tagListKey, 'tags'], []); - const tagListName = lodashGet(props.policyTags, [tagListKey, 'name'], ''); + const policyTag = PolicyUtils.getTag(props.policyTags); + const policyTagList = lodashGet(policyTag, 'tags', {}); + const policyTagListName = lodashGet(policyTag, 'name', translate('common.tag')); const canUseTags = Permissions.canUseTags(props.betas); // A flag for showing the tags field - const shouldShowTags = isPolicyExpenseChat && canUseTags && _.any(tagList, (tag) => tag.enabled); + const shouldShowTags = isPolicyExpenseChat && canUseTags && OptionsListUtils.hasEnabledOptions(_.values(policyTagList)); // A flag for showing the billable field const shouldShowBillable = canUseTags && !lodashGet(props.policy, 'disabledFields.defaultBillable', true); @@ -513,7 +508,7 @@ function MoneyRequestConfirmationList(props) { description={translate('common.distance')} style={[styles.moneyRequestMenuItem, styles.mb2]} titleStyle={styles.flex1} - onPress={() => Navigation.navigate(ROUTES.MONEY_REQUEST_ADDRESS.getRoute(props.iouType, props.reportID))} + onPress={() => Navigation.navigate(ROUTES.MONEY_REQUEST_DISTANCE.getRoute(props.iouType, props.reportID))} disabled={didConfirm || props.isReadOnly || !isTypeRequest} /> ) : ( @@ -541,7 +536,7 @@ function MoneyRequestConfirmationList(props) { Navigation.navigate(ROUTES.MONEY_REQUEST_TAG.getRoute(props.iouType, props.reportID))} style={[styles.moneyRequestMenuItem, styles.mb2]} disabled={didConfirm || props.isReadOnly} diff --git a/src/components/MultipleAvatars.js b/src/components/MultipleAvatars.js index 916646b5619a..1cdacb5fc1cc 100644 --- a/src/components/MultipleAvatars.js +++ b/src/components/MultipleAvatars.js @@ -25,7 +25,7 @@ const propTypes = { secondAvatarStyle: PropTypes.arrayOf(PropTypes.object), /** A fallback avatar icon to display when there is an error on loading avatar from remote URL. */ - fallbackIcon: PropTypes.func, + fallbackIcon: PropTypes.oneOfType([PropTypes.func, PropTypes.string]), /** Prop to identify if we should load avatars vertically instead of diagonally */ shouldStackHorizontally: PropTypes.bool, @@ -134,6 +134,7 @@ function MultipleAvatars(props) { fill={themeColors.iconSuccessFill} name={props.icons[0].name} type={props.icons[0].type} + fallbackIcon={props.icons[0].fallbackIcon} /> @@ -184,6 +185,7 @@ function MultipleAvatars(props) { size={props.size} name={icon.name} type={icon.type} + fallbackIcon={icon.fallbackIcon} /> @@ -249,6 +251,7 @@ function MultipleAvatars(props) { imageStyles={[singleAvatarStyle]} name={props.icons[0].name} type={props.icons[0].type} + fallbackIcon={props.icons[0].fallbackIcon} /> @@ -270,6 +273,7 @@ function MultipleAvatars(props) { imageStyles={[singleAvatarStyle]} name={props.icons[1].name} type={props.icons[1].type} + fallbackIcon={props.icons[1].fallbackIcon} /> diff --git a/src/components/OpacityView.js b/src/components/OpacityView.js index 2d09da744267..daef93cdc09b 100644 --- a/src/components/OpacityView.js +++ b/src/components/OpacityView.js @@ -3,6 +3,7 @@ import Animated, {useAnimatedStyle, useSharedValue, withTiming} from 'react-nati import PropTypes from 'prop-types'; import variables from '../styles/variables'; import * as StyleUtils from '../styles/StyleUtils'; +import shouldRenderOffscreen from '../libs/shouldRenderOffscreen'; const propTypes = { /** @@ -27,11 +28,15 @@ const propTypes = { * @default 0.5 */ dimmingValue: PropTypes.number, + + /** Whether the view needs to be rendered offscreen (for Android only) */ + needsOffscreenAlphaCompositing: PropTypes.bool, }; const defaultProps = { style: [], dimmingValue: variables.hoverDimValue, + needsOffscreenAlphaCompositing: false, }; function OpacityView(props) { @@ -48,7 +53,14 @@ function OpacityView(props) { } }, [props.shouldDim, props.dimmingValue, opacity]); - return {props.children}; + return ( + + {props.children} + + ); } OpacityView.displayName = 'OpacityView'; diff --git a/src/components/OptionRow.js b/src/components/OptionRow.js index a07510f7603d..70415ab03a13 100644 --- a/src/components/OptionRow.js +++ b/src/components/OptionRow.js @@ -212,6 +212,7 @@ class OptionRow extends Component { accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON} hoverDimmingValue={1} hoverStyle={this.props.hoverStyle} + needsOffscreenAlphaCompositing={lodashGet(this.props.option, 'icons.length', 0) >= 2} > @@ -220,18 +221,14 @@ class OptionRow extends Component { ) : ( ))} diff --git a/src/components/Pressable/PressableWithFeedback.js b/src/components/Pressable/PressableWithFeedback.js index 40be99823ceb..a80e2109ebd7 100644 --- a/src/components/Pressable/PressableWithFeedback.js +++ b/src/components/Pressable/PressableWithFeedback.js @@ -7,7 +7,7 @@ import OpacityView from '../OpacityView'; import variables from '../../styles/variables'; import useSingleExecution from '../../hooks/useSingleExecution'; -const omittedProps = ['wrapperStyle']; +const omittedProps = ['wrapperStyle', 'needsOffscreenAlphaCompositing']; const PressableWithFeedbackPropTypes = { ...GenericPressablePropTypes.pressablePropTypes, @@ -27,6 +27,9 @@ const PressableWithFeedbackPropTypes = { * Used to locate this view from native classes. */ nativeID: propTypes.string, + + /** Whether the view needs to be rendered offscreen (for Android only) */ + needsOffscreenAlphaCompositing: propTypes.bool, }; const PressableWithFeedbackDefaultProps = { @@ -35,10 +38,11 @@ const PressableWithFeedbackDefaultProps = { hoverDimmingValue: variables.hoverDimValue, nativeID: '', wrapperStyle: [], + needsOffscreenAlphaCompositing: false, }; const PressableWithFeedback = forwardRef((props, ref) => { - const propsWithoutWrapperStyles = _.omit(props, omittedProps); + const propsWithoutWrapperProps = _.omit(props, omittedProps); const {isExecuting, singleExecution} = useSingleExecution(); const [isPressed, setIsPressed] = useState(false); const [isHovered, setIsHovered] = useState(false); @@ -49,11 +53,12 @@ const PressableWithFeedback = forwardRef((props, ref) => { shouldDim={Boolean(!isDisabled && (isPressed || isHovered))} dimmingValue={isPressed ? props.pressDimmingValue : props.hoverDimmingValue} style={props.wrapperStyle} + needsOffscreenAlphaCompositing={props.needsOffscreenAlphaCompositing} > { diff --git a/src/components/PressableWithSecondaryInteraction/pressableWithSecondaryInteractionPropTypes.js b/src/components/PressableWithSecondaryInteraction/pressableWithSecondaryInteractionPropTypes.js index f521a57957f3..0a4f7949643a 100644 --- a/src/components/PressableWithSecondaryInteraction/pressableWithSecondaryInteractionPropTypes.js +++ b/src/components/PressableWithSecondaryInteraction/pressableWithSecondaryInteractionPropTypes.js @@ -48,6 +48,9 @@ const propTypes = { /** Used to apply styles to the Pressable */ style: stylePropTypes, + + /** Whether the view needs to be rendered offscreen (for Android only) */ + needsOffscreenAlphaCompositing: PropTypes.bool, }; const defaultProps = { @@ -59,6 +62,7 @@ const defaultProps = { withoutFocusOnSecondaryInteraction: false, activeOpacity: 1, enableLongPressWithHover: false, + needsOffscreenAlphaCompositing: false, }; export {propTypes, defaultProps}; diff --git a/src/components/Reactions/ReportActionItemEmojiReactions.js b/src/components/Reactions/ReportActionItemEmojiReactions.js index c1e1764ed9f1..e72c9d9381fa 100644 --- a/src/components/Reactions/ReportActionItemEmojiReactions.js +++ b/src/components/Reactions/ReportActionItemEmojiReactions.js @@ -13,7 +13,7 @@ import EmojiReactionsPropTypes from './EmojiReactionsPropTypes'; import Tooltip from '../Tooltip'; import ReactionTooltipContent from './ReactionTooltipContent'; import * as EmojiUtils from '../../libs/EmojiUtils'; -import ReportScreenContext from '../../pages/home/ReportScreenContext'; +import {ReactionListContext} from '../../pages/home/ReportScreenContext'; const propTypes = { emojiReactions: EmojiReactionsPropTypes, @@ -41,8 +41,9 @@ const defaultProps = { }; function ReportActionItemEmojiReactions(props) { - const {reactionListRef} = useContext(ReportScreenContext); + const reactionListRef = useContext(ReactionListContext); const popoverReactionListAnchors = useRef({}); + let totalReactionCount = 0; // Each emoji is sorted by the oldest timestamp of user reactions so that they will always appear in the same order for everyone diff --git a/src/components/ReportActionItem/MoneyRequestPreview.js b/src/components/ReportActionItem/MoneyRequestPreview.js index c0dde899ad8e..35215cadd15d 100644 --- a/src/components/ReportActionItem/MoneyRequestPreview.js +++ b/src/components/ReportActionItem/MoneyRequestPreview.js @@ -233,7 +233,12 @@ function MoneyRequestPreview(props) { errorRowStyles={[styles.mbn1]} needsOffscreenAlphaCompositing > - + {hasReceipt && ( ReportUtils.isPolicyExpenseChat(ReportUtils.getRootParentReport(report)), [report]); - // A flag for showing categories + + // Fetches only the first tag, for now + const policyTag = PolicyUtils.getTag(policyTags); + const policyTagsList = lodashGet(policyTag, 'tags', {}); + + // Flags for showing categories and tags const shouldShowCategory = isPolicyExpenseChat && Permissions.canUseCategories(betas) && (transactionCategory || OptionsListUtils.hasEnabledOptions(lodashValues(policyCategories))); + const shouldShowTag = isPolicyExpenseChat && Permissions.canUseTags(betas) && (transactionTag || OptionsListUtils.hasEnabledOptions(lodashValues(policyTagsList))); + const shouldShowBillable = isPolicyExpenseChat && Permissions.canUseTags(betas) && (transactionBillable || !lodashGet(policy, 'disabledFields.defaultBillable', true)); let description = `${translate('iou.amount')} • ${translate('iou.cash')}`; if (isSettled) { @@ -114,6 +132,8 @@ function MoneyRequestView({betas, report, parentReport, policyCategories, should } const isDistanceRequest = TransactionUtils.isDistanceRequest(transaction); + const pendingAction = lodashGet(transaction, 'pendingAction'); + const getPendingFieldAction = (fieldPath) => lodashGet(transaction, fieldPath) || pendingAction; return ( @@ -126,15 +146,17 @@ function MoneyRequestView({betas, report, parentReport, policyCategories, should {hasReceipt && ( - - - + + + + + )} - + Navigation.navigate(ROUTES.EDIT_REQUEST.getRoute(report.reportID, CONST.EDIT_REQUEST_FIELD.AMOUNT))} brickRoadIndicator={hasErrors && transactionAmount === 0 ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : ''} - subtitle={hasErrors && transactionAmount === 0 ? translate('common.error.enterAmount') : ''} - subtitleTextStyle={styles.textLabelError} + error={hasErrors && transactionAmount === 0 ? translate('common.error.enterAmount') : ''} /> - + - + Navigation.navigate(ROUTES.EDIT_REQUEST.getRoute(report.reportID, CONST.EDIT_REQUEST_FIELD.DATE))} brickRoadIndicator={hasErrors && transactionDate === '' ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : ''} - subtitle={hasErrors && transactionDate === '' ? translate('common.error.enterDate') : ''} - subtitleTextStyle={styles.textLabelError} - /> - - - Navigation.navigate(ROUTES.EDIT_REQUEST.getRoute(report.reportID, CONST.EDIT_REQUEST_FIELD.MERCHANT))} - brickRoadIndicator={hasErrors && isEmptyMerchant ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : ''} - subtitle={hasErrors && isEmptyMerchant ? translate('common.error.enterMerchant') : ''} - subtitleTextStyle={styles.textLabelError} + error={hasErrors && transactionDate === '' ? translate('common.error.enterDate') : ''} /> + {isDistanceRequest ? ( + + Navigation.navigate(ROUTES.EDIT_REQUEST.getRoute(report.reportID, CONST.EDIT_REQUEST_FIELD.DISTANCE))} + /> + + ) : ( + + Navigation.navigate(ROUTES.EDIT_REQUEST.getRoute(report.reportID, CONST.EDIT_REQUEST_FIELD.MERCHANT))} + brickRoadIndicator={hasErrors && isEmptyMerchant ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : ''} + subtitle={hasErrors && isEmptyMerchant ? translate('common.error.enterMerchant') : ''} + subtitleTextStyle={styles.textLabelError} + /> + + )} {shouldShowCategory && ( )} + {shouldShowTag && ( + + Navigation.navigate(ROUTES.EDIT_REQUEST.getRoute(report.reportID, CONST.EDIT_REQUEST_FIELD.TAG))} + /> + + )} + {shouldShowBillable && ( + + {translate('common.billable')} + IOU.editMoneyRequest(transaction.transactionID, report.reportID, {billable: value})} + /> + + )} `${ONYXKEYS.COLLECTION.POLICY_TAGS}${report.policyID}`, + }, }), )(MoneyRequestView); diff --git a/src/components/ReportActionItem/TaskPreview.js b/src/components/ReportActionItem/TaskPreview.js index 1da348bb067b..20ccd49620b1 100644 --- a/src/components/ReportActionItem/TaskPreview.js +++ b/src/components/ReportActionItem/TaskPreview.js @@ -94,9 +94,9 @@ function TaskPreview(props) { disabled={ReportUtils.isCanceledTaskReport(props.taskReport)} onPress={Session.checkIfActionIsAllowed(() => { if (isTaskCompleted) { - Task.reopenTask(props.taskReport, taskTitle); + Task.reopenTask(props.taskReport); } else { - Task.completeTask(props.taskReport, taskTitle); + Task.completeTask(props.taskReport); } })} accessibilityLabel={props.translate('task.task')} @@ -121,9 +121,11 @@ export default compose( withOnyx({ taskReport: { key: ({taskReportID}) => `${ONYXKEYS.COLLECTION.REPORT}${taskReportID}`, + initialValue: {}, }, personalDetailsList: { key: ONYXKEYS.PERSONAL_DETAILS_LIST, + initialValue: {}, }, }), )(TaskPreview); diff --git a/src/components/ReportActionItem/TaskView.js b/src/components/ReportActionItem/TaskView.js index 39807ab037d5..c52427ae1e8d 100644 --- a/src/components/ReportActionItem/TaskView.js +++ b/src/components/ReportActionItem/TaskView.js @@ -91,9 +91,9 @@ function TaskView(props) { { if (isCompleted) { - Task.reopenTask(props.report, taskTitle); + Task.reopenTask(props.report); } else { - Task.completeTask(props.report, taskTitle); + Task.completeTask(props.report); } })} isChecked={isCompleted} diff --git a/src/components/ReportActionsSkeletonView/index.js b/src/components/ReportActionsSkeletonView/index.js index 2fe7e590afef..6bdc993c2055 100644 --- a/src/components/ReportActionsSkeletonView/index.js +++ b/src/components/ReportActionsSkeletonView/index.js @@ -1,13 +1,10 @@ import React from 'react'; import PropTypes from 'prop-types'; -import {View} from 'react-native'; +import {View, Dimensions} from 'react-native'; import SkeletonViewLines from './SkeletonViewLines'; import CONST from '../../CONST'; const propTypes = { - /** Height of the container component */ - containerHeight: PropTypes.number.isRequired, - /** Whether to animate the skeleton view */ shouldAnimate: PropTypes.bool, }; @@ -18,7 +15,7 @@ const defaultProps = { function ReportActionsSkeletonView(props) { // Determines the number of content items based on container height - const possibleVisibleContentItems = Math.ceil(props.containerHeight / CONST.CHAT_SKELETON_VIEW.AVERAGE_ROW_HEIGHT); + const possibleVisibleContentItems = Math.ceil(Dimensions.get('window').height / CONST.CHAT_SKELETON_VIEW.AVERAGE_ROW_HEIGHT); const skeletonViewLines = []; for (let index = 0; index < possibleVisibleContentItems; index++) { const iconIndex = (index + 1) % 4; diff --git a/src/components/RoomHeaderAvatars.js b/src/components/RoomHeaderAvatars.js index af594dc2415a..92f294f056c7 100644 --- a/src/components/RoomHeaderAvatars.js +++ b/src/components/RoomHeaderAvatars.js @@ -49,6 +49,7 @@ function RoomHeaderAvatars(props) { size={CONST.AVATAR_SIZE.LARGE} name={props.icons[0].name} type={props.icons[0].type} + fallbackIcon={props.icons[0].fallbackIcon} /> )} @@ -93,6 +94,7 @@ function RoomHeaderAvatars(props) { containerStyles={[...iconStyle, StyleUtils.getAvatarBorderRadius(CONST.AVATAR_SIZE.LARGE_BORDERED, icon.type)]} name={icon.name} type={icon.type} + fallbackIcon={icon.fallbackIcon} /> )} diff --git a/src/components/SelectionList/BaseSelectionList.js b/src/components/SelectionList/BaseSelectionList.js index 0bb9cb0fdc6e..8d894e4c983a 100644 --- a/src/components/SelectionList/BaseSelectionList.js +++ b/src/components/SelectionList/BaseSelectionList.js @@ -136,6 +136,9 @@ function BaseSelectionList({ }; }, [canSelectMultiple, sections]); + // Disable `Enter` hotkey if the active element is a button or checkbox + const shouldDisableHotkeys = activeElement && [CONST.ACCESSIBILITY_ROLE.BUTTON, CONST.ACCESSIBILITY_ROLE.CHECKBOX].includes(activeElement.role); + // If `initiallyFocusedOptionKey` is not passed, we fall back to `-1`, to avoid showing the highlight on the first member const [focusedIndex, setFocusedIndex] = useState(() => _.findIndex(flattenedSections.allOptions, (option) => option.keyForList === initiallyFocusedOptionKey)); @@ -168,23 +171,35 @@ function BaseSelectionList({ listRef.current.scrollToLocation({sectionIndex: adjustedSectionIndex, itemIndex, animated, viewOffset: variables.contentHeaderHeight}); }; - const selectRow = (item, index) => { + /** + * Logic to run when a row is selected, either with click/press or keyboard hotkeys. + * + * @param {Object} item - the list item + * @param {Boolean} shouldUnfocusRow - flag to decide if we should unfocus all rows. True when selecting a row with click or press (not keyboard) + */ + const selectRow = (item, shouldUnfocusRow = false) => { // In single-selection lists we don't care about updating the focused index, because the list is closed after selecting an item if (canSelectMultiple) { - if (sections.length === 1) { - // If the list has only 1 section (e.g. Workspace Members list), we always focus the next available item - const nextAvailableIndex = _.findIndex(flattenedSections.allOptions, (option, i) => i > index && !option.isDisabled); - setFocusedIndex(nextAvailableIndex); - } else { - // If the list has multiple sections (e.g. Workspace Invite list), we focus the first one after all the selected (selected items are always at the top) + if (sections.length > 1) { + // If the list has only 1 section (e.g. Workspace Members list), we do nothing. + // If the list has multiple sections (e.g. Workspace Invite list), and `shouldUnfocusRow` is false, + // we focus the first one after all the selected (selected items are always at the top). const selectedOptionsCount = item.isSelected ? flattenedSections.selectedOptions.length - 1 : flattenedSections.selectedOptions.length + 1; - setFocusedIndex(selectedOptionsCount); + + if (!shouldUnfocusRow) { + setFocusedIndex(selectedOptionsCount); + } if (!item.isSelected) { // If we're selecting an item, scroll to it's position at the top, so we can see it scrollToIndex(Math.max(selectedOptionsCount - 1, 0), true); } } + + if (shouldUnfocusRow) { + // Unfocus all rows when selecting row with click/press + setFocusedIndex(-1); + } } onSelectRow(item); @@ -197,7 +212,7 @@ function BaseSelectionList({ return; } - selectRow(focusedOption, focusedIndex); + selectRow(focusedOption); }; /** @@ -254,7 +269,7 @@ function BaseSelectionList({ selectRow(item, index)} + onSelectRow={() => selectRow(item, true)} onDismissError={onDismissError} showTooltip={showTooltip} /> @@ -266,7 +281,7 @@ function BaseSelectionList({ item={item} isFocused={isItemFocused} isDisabled={isDisabled} - onSelectRow={() => selectRow(item, index)} + onSelectRow={() => selectRow(item, true)} /> ); }; @@ -290,7 +305,7 @@ function BaseSelectionList({ useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.ENTER, selectFocusedOption, { captureOnInputs: true, shouldBubble: () => !flattenedSections.allOptions[focusedIndex], - isActive: !activeElement && isFocused, + isActive: !shouldDisableHotkeys && isFocused, }); /** Calls confirm action when pressing CTRL (CMD) + Enter */ diff --git a/src/components/SelectionList/RadioListItem.js b/src/components/SelectionList/RadioListItem.js index df022992e24a..530af66d91d3 100644 --- a/src/components/SelectionList/RadioListItem.js +++ b/src/components/SelectionList/RadioListItem.js @@ -18,7 +18,6 @@ function RadioListItem({item, isFocused = false, isDisabled = false, onSelectRow accessibilityRole="button" hoverDimmingValue={1} hoverStyle={styles.hoveredComponentBG} - focusStyle={styles.hoveredComponentBG} dataSet={{[CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT]: true}} > diff --git a/src/components/SelectionList/UserListItem.js b/src/components/SelectionList/UserListItem.js index 014e0cf879a5..0d37162a7995 100644 --- a/src/components/SelectionList/UserListItem.js +++ b/src/components/SelectionList/UserListItem.js @@ -25,6 +25,7 @@ function UserListItem({item, isFocused = false, showTooltip, onSelectRow, onDism source={lodashGet(item, 'avatar.source', '')} name={lodashGet(item, 'avatar.name', item.text)} type={lodashGet(item, 'avatar.type', CONST.ICON_TYPE_AVATAR)} + fallbackIcon={lodashGet(item, 'avatar.fallbackIcon')} /> ); @@ -62,7 +63,6 @@ function UserListItem({item, isFocused = false, showTooltip, onSelectRow, onDism accessibilityState={{checked: item.isSelected}} hoverDimmingValue={1} hoverStyle={styles.hoveredComponentBG} - focusStyle={styles.hoveredComponentBG} dataSet={{[CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT]: true}} > diff --git a/src/components/StatePicker/index.js b/src/components/StatePicker/index.js index 142654b82cd1..f7f894af2a07 100644 --- a/src/components/StatePicker/index.js +++ b/src/components/StatePicker/index.js @@ -49,7 +49,9 @@ function StatePicker({value, errorText, onInputChange, forwardedRef, label}) { }; const updateStateInput = (state) => { - onInputChange(state.value); + if (state.value !== value) { + onInputChange(state.value); + } hidePickerModal(); }; diff --git a/src/components/SubscriptAvatar.js b/src/components/SubscriptAvatar.js index 038484e3f42d..81864d6e5af2 100644 --- a/src/components/SubscriptAvatar.js +++ b/src/components/SubscriptAvatar.js @@ -60,6 +60,7 @@ function SubscriptAvatar(props) { size={props.size || CONST.AVATAR_SIZE.DEFAULT} name={props.mainAvatar.name} type={props.mainAvatar.type} + fallbackIcon={props.mainAvatar.fallbackIcon} /> @@ -83,6 +84,7 @@ function SubscriptAvatar(props) { fill={themeColors.iconSuccessFill} name={props.secondaryAvatar.name} type={props.secondaryAvatar.type} + fallbackIcon={props.secondaryAvatar.fallbackIcon} /> diff --git a/src/components/TagPicker/index.js b/src/components/TagPicker/index.js index c46ca1b57b22..8e7cf11f7e5a 100644 --- a/src/components/TagPicker/index.js +++ b/src/components/TagPicker/index.js @@ -7,6 +7,7 @@ import ONYXKEYS from '../../ONYXKEYS'; import styles from '../../styles/styles'; import useLocalize from '../../hooks/useLocalize'; import * as OptionsListUtils from '../../libs/OptionsListUtils'; +import * as PolicyUtils from '../../libs/PolicyUtils'; import OptionsSelector from '../OptionsSelector'; import {propTypes, defaultProps} from './tagPickerPropTypes'; @@ -15,7 +16,7 @@ function TagPicker({selectedTag, tag, policyTags, policyRecentlyUsedTags, onSubm const [searchValue, setSearchValue] = useState(''); const policyRecentlyUsedTagsList = lodashGet(policyRecentlyUsedTags, tag, []); - const policyTagList = lodashGet(policyTags, [tag, 'tags'], {}); + const policyTagList = PolicyUtils.getTagList(policyTags, tag); const policyTagsCount = _.size(_.filter(policyTagList, (policyTag) => policyTag.enabled)); const isTagsCountBelowThreshold = policyTagsCount < CONST.TAG_LIST_THRESHOLD; diff --git a/src/components/TagPicker/tagPickerPropTypes.js b/src/components/TagPicker/tagPickerPropTypes.js index a5d94605a76a..011885fe0f81 100644 --- a/src/components/TagPicker/tagPickerPropTypes.js +++ b/src/components/TagPicker/tagPickerPropTypes.js @@ -16,12 +16,7 @@ const propTypes = { /* Onyx Props */ /** Collection of tags attached to a policy */ - policyTags: PropTypes.objectOf( - PropTypes.shape({ - name: PropTypes.string, - tags: PropTypes.objectOf(tagPropTypes), - }), - ), + policyTags: tagPropTypes, /** List of recently used tags */ policyRecentlyUsedTags: PropTypes.objectOf(PropTypes.arrayOf(PropTypes.string)), diff --git a/src/components/TaskHeaderActionButton.js b/src/components/TaskHeaderActionButton.js index aa7694095f5b..b22b5c92bf70 100644 --- a/src/components/TaskHeaderActionButton.js +++ b/src/components/TaskHeaderActionButton.js @@ -36,10 +36,8 @@ function TaskHeaderActionButton(props) { success isDisabled={ReportUtils.isCanceledTaskReport(props.report) || !Task.canModifyTask(props.report, props.session.accountID)} medium - text={props.translate(ReportUtils.isCompletedTaskReport(props.report) ? 'task.markAsIncomplete' : 'task.markAsDone')} - onPress={() => - ReportUtils.isCompletedTaskReport(props.report) ? Task.reopenTask(props.report, props.report.reportName) : Task.completeTask(props.report, props.report.reportName) - } + text={props.translate(ReportUtils.isCompletedTaskReport(props.report) ? 'task.markAsIncomplete' : 'task.markAsComplete')} + onPress={() => (ReportUtils.isCompletedTaskReport(props.report) ? Task.reopenTask(props.report) : Task.completeTask(props.report))} style={[styles.flex1]} /> diff --git a/src/components/TextInput/BaseTextInput.js b/src/components/TextInput/BaseTextInput.js index 244d6a8d8490..b469f39c7037 100644 --- a/src/components/TextInput/BaseTextInput.js +++ b/src/components/TextInput/BaseTextInput.js @@ -1,5 +1,5 @@ import _ from 'underscore'; -import React, {useState, useRef, useEffect, useCallback} from 'react'; +import React, {useState, useRef, useEffect, useCallback, useMemo} from 'react'; import {Animated, View, StyleSheet} from 'react-native'; import Str from 'expensify-common/lib/str'; import RNTextInput from '../RNTextInput'; @@ -21,6 +21,7 @@ import isInputAutoFilled from '../../libs/isInputAutoFilled'; import PressableWithoutFeedback from '../Pressable/PressableWithoutFeedback'; import withLocalize from '../withLocalize'; import useNativeDriver from '../../libs/useNativeDriver'; +import * as Browser from '../../libs/Browser'; function BaseTextInput(props) { const inputValue = props.value || props.defaultValue || ''; @@ -235,6 +236,25 @@ function BaseTextInput(props) { ]); const isMultiline = props.multiline || props.autoGrowHeight; + /* To prevent text jumping caused by virtual DOM calculations on Safari and mobile Chrome, + make sure to include the `lineHeight`. + Reference: https://github.com/Expensify/App/issues/26735 + + For other platforms, explicitly remove `lineHeight` from single-line inputs + to prevent long text from disappearing once it exceeds the input space. + See https://github.com/Expensify/App/issues/13802 */ + const lineHeight = useMemo(() => { + if (Browser.isSafari() && _.isArray(props.inputStyle)) { + const lineHeightValue = _.find(props.inputStyle, (f) => f.lineHeight !== undefined); + if (lineHeightValue) { + return lineHeightValue.lineHeight; + } + } else if (Browser.isSafari() || Browser.isMobileChrome()) { + return height; + } + return undefined; + }, [props.inputStyle, height]); + return ( <> @@ -320,7 +340,7 @@ function BaseTextInput(props) { // Explicitly remove `lineHeight` from single line inputs so that long text doesn't disappear // once it exceeds the input space (See https://github.com/Expensify/App/issues/13802) - !isMultiline && {height, lineHeight: undefined}, + !isMultiline && {height, lineHeight}, // Stop scrollbar flashing when breaking lines with autoGrowHeight enabled. props.autoGrowHeight && StyleUtils.getAutoGrowHeightInputStyle(textInputHeight, maxHeight), @@ -382,11 +402,17 @@ function BaseTextInput(props) { This Text component is intentionally positioned out of the screen. */} {(props.autoGrow || props.autoGrowHeight) && ( - // Add +2 to width so that the first digit of amount do not cut off on mWeb - https://github.com/Expensify/App/issues/8158. + // Add +2 to width on Safari browsers so that text is not cut off due to the cursor or when changing the value + // https://github.com/Expensify/App/issues/8158 + // https://github.com/Expensify/App/issues/26628 { - setTextInputWidth(e.nativeEvent.layout.width + 2); + let additionalWidth = 0; + if (Browser.isMobileSafari() || Browser.isSafari()) { + additionalWidth = 2; + } + setTextInputWidth(e.nativeEvent.layout.width + additionalWidth); setTextInputHeight(e.nativeEvent.layout.height); }} > diff --git a/src/components/UserDetailsTooltip/index.web.js b/src/components/UserDetailsTooltip/index.web.js index 1a78459d30a6..e961c237ae5f 100644 --- a/src/components/UserDetailsTooltip/index.web.js +++ b/src/components/UserDetailsTooltip/index.web.js @@ -48,6 +48,7 @@ function UserDetailsTooltip(props) { source={props.icon ? props.icon.source : UserUtils.getAvatar(userAvatar, userAccountID)} type={props.icon ? props.icon.type : CONST.ICON_TYPE_AVATAR} name={props.icon ? props.icon.name : userLogin} + fallbackIcon={lodashGet(props.icon, 'fallbackIcon')} /> {title} diff --git a/src/components/ValidateCode/ValidateCodeModal.js b/src/components/ValidateCode/ValidateCodeModal.js index eabb21eea4a9..ceebc54a47af 100644 --- a/src/components/ValidateCode/ValidateCodeModal.js +++ b/src/components/ValidateCode/ValidateCodeModal.js @@ -39,7 +39,7 @@ const defaultProps = { }; function ValidateCodeModal(props) { - const signInHere = useCallback(() => Session.signInWithValidateCode(props.accountID, props.code, props.preferredLocale), [props.accountID, props.code, props.preferredLocale]); + const signInHere = useCallback(() => Session.signInWithValidateCode(props.accountID, props.code), [props.accountID, props.code]); return ( diff --git a/src/components/WalletStatementModal/index.js b/src/components/WalletStatementModal/index.js index 84109217b18f..b4d87bf4e0e4 100644 --- a/src/components/WalletStatementModal/index.js +++ b/src/components/WalletStatementModal/index.js @@ -12,6 +12,7 @@ import FullScreenLoadingIndicator from '../FullscreenLoadingIndicator'; import ROUTES from '../../ROUTES'; import Navigation from '../../libs/Navigation/Navigation'; import * as Report from '../../libs/actions/Report'; +import CONST from '../../CONST'; function WalletStatementModal({statementPageURL, session}) { const [isLoading, setIsLoading] = useState(true); @@ -23,15 +24,15 @@ function WalletStatementModal({statementPageURL, session}) { * @param {MessageEvent} event */ const navigate = (event) => { - if (!event.data || !event.data.type || (event.data.type !== 'STATEMENT_NAVIGATE' && event.data.type !== 'CONCIERGE_NAVIGATE')) { + if (!event.data || !event.data.type || (event.data.type !== CONST.WALLET.WEB_MESSAGE_TYPE.STATEMENT && event.data.type !== CONST.WALLET.WEB_MESSAGE_TYPE.CONCIERGE)) { return; } - if (event.data.type === 'CONCIERGE_NAVIGATE') { + if (event.data.type === CONST.WALLET.WEB_MESSAGE_TYPE.CONCIERGE) { Report.navigateToConciergeChat(); } - if (event.data.type === 'STATEMENT_NAVIGATE' && event.data.url) { + if (event.data.type === CONST.WALLET.WEB_MESSAGE_TYPE.STATEMENT && event.data.url) { const iouRoutes = [ROUTES.IOU_REQUEST, ROUTES.IOU_SEND]; const navigateToIOURoute = _.find(iouRoutes, (iouRoute) => event.data.url.includes(iouRoute)); if (navigateToIOURoute) { diff --git a/src/components/WalletStatementModal/index.native.js b/src/components/WalletStatementModal/index.native.js index 590431274da5..38d1f90af00d 100644 --- a/src/components/WalletStatementModal/index.native.js +++ b/src/components/WalletStatementModal/index.native.js @@ -1,24 +1,22 @@ -import React from 'react'; +import React, {useCallback, useRef} from 'react'; import {WebView} from 'react-native-webview'; import lodashGet from 'lodash/get'; import {withOnyx} from 'react-native-onyx'; import _ from 'underscore'; -import withLocalize from '../withLocalize'; -import ONYXKEYS from '../../ONYXKEYS'; -import compose from '../../libs/compose'; import {walletStatementPropTypes, walletStatementDefaultProps} from './WalletStatementModalPropTypes'; import FullScreenLoadingIndicator from '../FullscreenLoadingIndicator'; import * as Report from '../../libs/actions/Report'; import Navigation from '../../libs/Navigation/Navigation'; import ROUTES from '../../ROUTES'; +import ONYXKEYS from '../../ONYXKEYS'; +import CONST from '../../CONST'; -class WalletStatementModal extends React.Component { - constructor(props) { - super(props); +const IOU_ROUTES = [ROUTES.IOU_REQUEST, ROUTES.IOU_SEND]; +const renderLoading = () => ; - this.authToken = lodashGet(props, 'session.authToken', null); - this.navigate = this.navigate.bind(this); - } +function WalletStatementModal({statementPageURL, session}) { + const webViewRef = useRef(); + const authToken = lodashGet(session, 'authToken', null); /** * Handles in-app navigation for webview links @@ -26,54 +24,53 @@ class WalletStatementModal extends React.Component { * @param {String} params.type * @param {String} params.url */ - navigate({type, url}) { - if (!this.webview || (type !== 'STATEMENT_NAVIGATE' && type !== 'CONCIERGE_NAVIGATE')) { - return; - } + const handleNavigationStateChange = useCallback( + ({type, url}) => { + if (!webViewRef.current || (type !== CONST.WALLET.WEB_MESSAGE_TYPE.STATEMENT && type !== CONST.WALLET.WEB_MESSAGE_TYPE.CONCIERGE)) { + return; + } - if (type === 'CONCIERGE_NAVIGATE') { - this.webview.stopLoading(); - Report.navigateToConciergeChat(); - } + if (type === CONST.WALLET.WEB_MESSAGE_TYPE.CONCIERGE) { + webViewRef.current.stopLoading(); + Report.navigateToConciergeChat(); + } - if (type === 'STATEMENT_NAVIGATE' && url) { - const iouRoutes = [ROUTES.IOU_REQUEST, ROUTES.IOU_SEND]; - const navigateToIOURoute = _.find(iouRoutes, (iouRoute) => url.includes(iouRoute)); - if (navigateToIOURoute) { - this.webview.stopLoading(); - Navigation.navigate(navigateToIOURoute); + if (type === CONST.WALLET.WEB_MESSAGE_TYPE.STATEMENT && url) { + const iouRoute = _.find(IOU_ROUTES, (item) => url.includes(item)); + + if (iouRoute) { + webViewRef.current.stopLoading(); + Navigation.navigate(iouRoute); + } } - } - } + }, + [webViewRef], + ); - render() { - return ( - (this.webview = node)} - originWhitelist={['https://*']} - source={{ - uri: this.props.statementPageURL, - headers: { - Cookie: `authToken=${this.authToken}`, - }, - }} - incognito // 'incognito' prop required for Android, issue here https://github.com/react-native-webview/react-native-webview/issues/1352 - startInLoadingState - renderLoading={() => } - onNavigationStateChange={this.navigate} - /> - ); - } + return ( + + ); } +WalletStatementModal.displayName = 'WalletStatementModal'; WalletStatementModal.propTypes = walletStatementPropTypes; WalletStatementModal.defaultProps = walletStatementDefaultProps; -export default compose( - withLocalize, - withOnyx({ - session: { - key: ONYXKEYS.SESSION, - }, - }), -)(WalletStatementModal); +export default withOnyx({ + session: { + key: ONYXKEYS.SESSION, + }, +})(WalletStatementModal); diff --git a/src/components/avatarPropTypes.js b/src/components/avatarPropTypes.js index 12ee5c622b4f..915eac995fcb 100644 --- a/src/components/avatarPropTypes.js +++ b/src/components/avatarPropTypes.js @@ -6,4 +6,5 @@ export default PropTypes.shape({ type: PropTypes.oneOf([CONST.ICON_TYPE_AVATAR, CONST.ICON_TYPE_WORKSPACE]), name: PropTypes.string, id: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), + fallbackIcon: PropTypes.oneOfType([PropTypes.func, PropTypes.string]), }); diff --git a/src/components/menuItemPropTypes.js b/src/components/menuItemPropTypes.js index 53216ab7cdc7..e1d10ca95971 100644 --- a/src/components/menuItemPropTypes.js +++ b/src/components/menuItemPropTypes.js @@ -88,9 +88,6 @@ const propTypes = { /** A right-aligned subtitle for this menu option */ subtitle: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), - /** Style for the subtitle */ - subtitleTextStyle: stylePropTypes, - /** Flag to choose between avatar image or an icon */ iconType: PropTypes.oneOf([CONST.ICON_TYPE_AVATAR, CONST.ICON_TYPE_ICON, CONST.ICON_TYPE_WORKSPACE]), @@ -98,7 +95,7 @@ const propTypes = { interactive: PropTypes.bool, /** A fallback avatar icon to display when there is an error on loading avatar from remote URL. */ - fallbackIcon: PropTypes.func, + fallbackIcon: PropTypes.oneOfType([PropTypes.func, PropTypes.string]), /** Avatars to show on the right of the menu item */ floatRightAvatars: PropTypes.arrayOf(avatarPropTypes), @@ -145,6 +142,9 @@ const propTypes = { /** Should we grey out the menu item when it is disabled? */ shouldGreyOutWhenDisabled: PropTypes.bool, + /** Error to display below the title */ + error: PropTypes.string, + /** Should render the content in HTML format */ shouldRenderAsHTML: PropTypes.bool, }; diff --git a/src/components/tagPropTypes.js b/src/components/tagPropTypes.js index 29d913dcd035..2108b65ebbd5 100644 --- a/src/components/tagPropTypes.js +++ b/src/components/tagPropTypes.js @@ -1,6 +1,6 @@ import PropTypes from 'prop-types'; -export default PropTypes.shape({ +const tagListPropTypes = PropTypes.shape({ /** Name of a tag */ name: PropTypes.string.isRequired, @@ -10,3 +10,11 @@ export default PropTypes.shape({ /** "General Ledger code" that corresponds to this tag in an accounting system. Similar to an ID. */ 'GL Code': PropTypes.string, }); + +export default PropTypes.objectOf( + PropTypes.shape({ + name: PropTypes.string, + required: PropTypes.bool, + tags: PropTypes.objectOf(tagListPropTypes), + }), +); diff --git a/src/components/withKeyboardState.js b/src/components/withKeyboardState.js index 667e8865a0e3..8ddf667b4e30 100755 --- a/src/components/withKeyboardState.js +++ b/src/components/withKeyboardState.js @@ -1,7 +1,6 @@ -/* eslint-disable react/no-unused-state */ -import React, {forwardRef, createContext} from 'react'; -import PropTypes from 'prop-types'; +import React, {forwardRef, createContext, useEffect, useState} from 'react'; import {Keyboard} from 'react-native'; +import PropTypes from 'prop-types'; import getComponentDisplayName from '../libs/getComponentDisplayName'; const KeyboardStateContext = createContext(null); @@ -15,32 +14,24 @@ const keyboardStateProviderPropTypes = { children: PropTypes.node.isRequired, }; -class KeyboardStateProvider extends React.Component { - constructor(props) { - super(props); - - this.state = { - isKeyboardShown: false, - }; - } - - componentDidMount() { - this.keyboardDidShowListener = Keyboard.addListener('keyboardDidShow', () => { - this.setState({isKeyboardShown: true}); +function KeyboardStateProvider(props) { + const {children} = props; + const [isKeyboardShown, setIsKeyboardShown] = useState(false); + useEffect(() => { + const keyboardDidShowListener = Keyboard.addListener('keyboardDidShow', () => { + setIsKeyboardShown(true); }); - this.keyboardDidHideListener = Keyboard.addListener('keyboardDidHide', () => { - this.setState({isKeyboardShown: false}); + const keyboardDidHideListener = Keyboard.addListener('keyboardDidHide', () => { + setIsKeyboardShown(false); }); - } - componentWillUnmount() { - this.keyboardDidShowListener.remove(); - this.keyboardDidHideListener.remove(); - } + return () => { + keyboardDidShowListener.remove(); + keyboardDidHideListener.remove(); + }; + }, []); - render() { - return {this.props.children}; - } + return {children}; } KeyboardStateProvider.propTypes = keyboardStateProviderPropTypes; diff --git a/src/components/withLocalize.js b/src/components/withLocalize.js index 6c588698ce9d..5ce1b0bc6d74 100755 --- a/src/components/withLocalize.js +++ b/src/components/withLocalize.js @@ -28,6 +28,9 @@ const withLocalizePropTypes = { /** Formats a datetime to local date and time string */ datetimeToCalendarTime: PropTypes.func.isRequired, + /** Updates date-fns internal locale */ + updateLocale: PropTypes.func.isRequired, + /** Returns a locally converted phone number for numbers from the same region * and an internationally converted phone number with the country code for numbers from other regions */ formatPhoneNumber: PropTypes.func.isRequired, @@ -79,6 +82,7 @@ class LocaleContextProvider extends React.Component { numberFormat: this.numberFormat.bind(this), datetimeToRelative: this.datetimeToRelative.bind(this), datetimeToCalendarTime: this.datetimeToCalendarTime.bind(this), + updateLocale: this.updateLocale.bind(this), formatPhoneNumber: this.formatPhoneNumber.bind(this), fromLocaleDigit: this.fromLocaleDigit.bind(this), toLocaleDigit: this.toLocaleDigit.bind(this), @@ -122,6 +126,13 @@ class LocaleContextProvider extends React.Component { return DateUtils.datetimeToCalendarTime(this.props.preferredLocale, datetime, includeTimezone, lodashGet(this.props, 'currentUserPersonalDetails.timezone.selected'), isLowercase); } + /** + * Updates date-fns internal locale to the user preferredLocale + */ + updateLocale() { + DateUtils.setLocale(this.props.preferredLocale); + } + /** * @param {String} phoneNumber * @returns {String} diff --git a/src/hooks/useActiveElement/index.js b/src/hooks/useActiveElement/index.js index c973eb8eda18..0db0ed604067 100644 --- a/src/hooks/useActiveElement/index.js +++ b/src/hooks/useActiveElement/index.js @@ -1,5 +1,11 @@ import {useEffect, useState} from 'react'; +/** + * Listens for the focusin and focusout events and sets the DOM activeElement to the state. + * On native, we just return null. + * + * @return {Element} the active element in the DOM + */ export default function useActiveElement() { const [active, setActive] = useState(document.activeElement); diff --git a/src/hooks/useActiveElement/index.native.js b/src/hooks/useActiveElement/index.native.js index 2f658d48ca9a..afdfe8a047e4 100644 --- a/src/hooks/useActiveElement/index.native.js +++ b/src/hooks/useActiveElement/index.native.js @@ -1,3 +1,8 @@ +/** + * Native doesn't have the DOM, so we just return null. + * + * @return {null} + */ export default function useActiveElement() { return null; } diff --git a/src/hooks/useDebounce.js b/src/hooks/useDebounce.js new file mode 100644 index 000000000000..8995a0443b85 --- /dev/null +++ b/src/hooks/useDebounce.js @@ -0,0 +1,37 @@ +import {useEffect, useRef} from 'react'; +import lodashDebounce from 'lodash/debounce'; + +/** + * Create and return a debounced function. + * + * Every time the identity of any of the arguments changes, the debounce operation will restart (canceling any ongoing debounce). + * This is especially important in the case of func. To prevent that, pass stable references. + * + * @param {Function} func The function to debounce. + * @param {Number} wait The number of milliseconds to delay. + * @param {Object} options The options object. + * @param {Boolean} options.leading Specify invoking on the leading edge of the timeout. + * @param {Number} options.maxWait The maximum time func is allowed to be delayed before it’s invoked. + * @param {Boolean} options.trailing Specify invoking on the trailing edge of the timeout. + * @returns {Function} Returns a function to call the debounced function. + */ +export default function useDebounce(func, wait, options) { + const debouncedFnRef = useRef(); + const {leading, maxWait, trailing = true} = options || {}; + + useEffect(() => { + const debouncedFn = lodashDebounce(func, wait, {leading, maxWait, trailing}); + + debouncedFnRef.current = debouncedFn; + + return debouncedFn.cancel; + }, [func, wait, leading, maxWait, trailing]); + + return (...args) => { + const debouncedFn = debouncedFnRef.current; + + if (debouncedFn) { + debouncedFn(...args); + } + }; +} diff --git a/src/hooks/useReportScrollManager/index.js b/src/hooks/useReportScrollManager/index.js index 0cf09146553c..9a3303504b92 100644 --- a/src/hooks/useReportScrollManager/index.js +++ b/src/hooks/useReportScrollManager/index.js @@ -1,8 +1,8 @@ import {useContext, useCallback} from 'react'; -import ReportScreenContext from '../../pages/home/ReportScreenContext'; +import {ActionListContext} from '../../pages/home/ReportScreenContext'; function useReportScrollManager() { - const {flatListRef} = useContext(ReportScreenContext); + const flatListRef = useContext(ActionListContext); /** * Scroll to the provided index. On non-native implementations we do not want to scroll when we are scrolling because diff --git a/src/hooks/useReportScrollManager/index.native.js b/src/hooks/useReportScrollManager/index.native.js index 35af064cb062..d44a40222ca5 100644 --- a/src/hooks/useReportScrollManager/index.native.js +++ b/src/hooks/useReportScrollManager/index.native.js @@ -1,8 +1,8 @@ import {useContext, useCallback} from 'react'; -import ReportScreenContext from '../../pages/home/ReportScreenContext'; +import {ActionListContext} from '../../pages/home/ReportScreenContext'; function useReportScrollManager() { - const {flatListRef} = useContext(ReportScreenContext); + const flatListRef = useContext(ActionListContext); /** * Scroll to the provided index. diff --git a/src/languages/en.ts b/src/languages/en.ts index def4b351e112..dfad8883e270 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -244,6 +244,7 @@ export default { merchant: 'Merchant', category: 'Category', billable: 'Billable', + nonBillable: 'Non-billable', tag: 'Tag', receipt: 'Receipt', replace: 'Replace', @@ -809,6 +810,12 @@ export default { }, addBankAccountFailure: 'An unexpected error occurred while trying to add your bank account. Please try again.', }, + cardPage: { + expensifyCard: 'Expensify Card', + availableSpend: 'Remaining spending power', + virtualCardNumber: 'Virtual card number', + physicalCardNumber: 'Physical card number', + }, transferAmountPage: { transfer: ({amount}: TransferParams) => `Transfer${amount ? ` ${amount}` : ''}`, instant: 'Instant (Debit card)', @@ -1065,7 +1072,7 @@ export default { noBankAccountSelected: 'Please choose an account', taxID: 'Please enter a valid tax ID number', website: 'Please enter a valid website', - zipCode: 'Please enter a valid zip code', + zipCode: `Incorrect zip code format. Acceptable format: ${CONST.COUNTRY_ZIP_REGEX_DATA.US.samples}`, phoneNumber: 'Please enter a valid phone number', companyName: 'Please enter a valid legal business name', addressCity: 'Please enter a valid city', @@ -1527,12 +1534,12 @@ export default { assignee: 'Assignee', completed: 'Completed', messages: { - completed: 'completed task', + completed: 'marked as complete', canceled: 'deleted task', - reopened: 'reopened task', + reopened: 'marked as incomplete', error: 'You do not have the permission to do the requested action.', }, - markAsDone: 'Mark as done', + markAsComplete: 'Mark as complete', markAsIncomplete: 'Mark as incomplete', assigneeError: 'There was an error assigning this task, please try another assignee.', }, diff --git a/src/languages/es.ts b/src/languages/es.ts index a78a30375fe9..6ffbda2648c6 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -234,6 +234,7 @@ export default { merchant: 'Comerciante', category: 'Categoría', billable: 'Facturable', + nonBillable: 'No facturable', tag: 'Etiqueta', receipt: 'Recibo', replace: 'Sustituir', @@ -804,6 +805,12 @@ export default { }, addBankAccountFailure: 'Ocurrió un error inesperado al intentar añadir la cuenta bancaria. Inténtalo de nuevo.', }, + cardPage: { + expensifyCard: 'Tarjeta Expensify', + availableSpend: 'Capacidad de gasto restante', + virtualCardNumber: 'Número de la tarjeta virtual', + physicalCardNumber: 'Número de la tarjeta física', + }, transferAmountPage: { transfer: ({amount}: TransferParams) => `Transferir${amount ? ` ${amount}` : ''}`, instant: 'Instante', @@ -1080,7 +1087,7 @@ export default { noBankAccountSelected: 'Por favor, elige una cuenta bancaria', taxID: 'Por favor, introduce un número de identificación fiscal válido', website: 'Por favor, introduce un sitio web válido', - zipCode: 'Por favor, introduce un código postal válido', + zipCode: `Formato de código postal incorrecto. Formato aceptable: ${CONST.COUNTRY_ZIP_REGEX_DATA.US.samples}`, phoneNumber: 'Por favor, introduce un teléfono válido', companyName: 'Por favor, introduce un nombre comercial legal válido', addressCity: 'Por favor, introduce una ciudad válida', @@ -1549,12 +1556,12 @@ export default { assignee: 'Usuario asignado', completed: 'Completada', messages: { - completed: 'tarea completada', + completed: 'marcada como completa', canceled: 'tarea eliminado', - reopened: 'tarea reabrir', + reopened: 'marcada como incompleta', error: 'No tiene permiso para realizar la acción solicitada.', }, - markAsDone: 'Marcar como completada', + markAsComplete: 'Marcar como completada', markAsIncomplete: 'Marcar como incompleta', assigneeError: 'Hubo un error al asignar esta tarea, inténtalo con otro usuario.', }, diff --git a/src/libs/Accessibility/index.js b/src/libs/Accessibility/index.ts similarity index 74% rename from src/libs/Accessibility/index.js rename to src/libs/Accessibility/index.ts index 59a6738dfb14..213d28139c2c 100644 --- a/src/libs/Accessibility/index.js +++ b/src/libs/Accessibility/index.ts @@ -1,25 +1,28 @@ import {useEffect, useState, useCallback} from 'react'; -import {AccessibilityInfo} from 'react-native'; -import _ from 'underscore'; +import {AccessibilityInfo, LayoutChangeEvent} from 'react-native'; import moveAccessibilityFocus from './moveAccessibilityFocus'; -const useScreenReaderStatus = () => { +type HitSlop = {x: number; y: number}; + +const useScreenReaderStatus = (): boolean => { const [isScreenReaderEnabled, setIsScreenReaderEnabled] = useState(false); useEffect(() => { const subscription = AccessibilityInfo.addEventListener('screenReaderChanged', setIsScreenReaderEnabled); - return subscription && subscription.remove; + return () => { + subscription?.remove(); + }; }, []); return isScreenReaderEnabled; }; -const getHitSlopForSize = ({x, y}) => { +const getHitSlopForSize = ({x, y}: HitSlop) => { /* according to https://developer.apple.com/design/human-interface-guidelines/ios/visual-design/adaptivity-and-layout/ the minimum tappable area is 44x44 points */ const minimumSize = 44; - const hitSlopVertical = _.max([minimumSize - x, 0]) / 2; - const hitSlopHorizontal = _.max([minimumSize - y, 0]) / 2; + const hitSlopVertical = Math.max(minimumSize - x, 0) / 2; + const hitSlopHorizontal = Math.max(minimumSize - y, 0) / 2; return { top: hitSlopVertical, bottom: hitSlopVertical, @@ -31,7 +34,7 @@ const getHitSlopForSize = ({x, y}) => { const useAutoHitSlop = () => { const [frameSize, setFrameSize] = useState({x: 0, y: 0}); const onLayout = useCallback( - (event) => { + (event: LayoutChangeEvent) => { const {layout} = event.nativeEvent; if (layout.width !== frameSize.x && layout.height !== frameSize.y) { setFrameSize({x: layout.width, y: layout.height}); diff --git a/src/libs/Accessibility/moveAccessibilityFocus/index.js b/src/libs/Accessibility/moveAccessibilityFocus/index.js deleted file mode 100644 index c9130c7e34be..000000000000 --- a/src/libs/Accessibility/moveAccessibilityFocus/index.js +++ /dev/null @@ -1,8 +0,0 @@ -const moveAccessibilityFocus = (ref) => { - if (!ref || !ref.current) { - return; - } - ref.current.focus(); -}; - -export default moveAccessibilityFocus; diff --git a/src/libs/Accessibility/moveAccessibilityFocus/index.native.js b/src/libs/Accessibility/moveAccessibilityFocus/index.native.ts similarity index 62% rename from src/libs/Accessibility/moveAccessibilityFocus/index.native.js rename to src/libs/Accessibility/moveAccessibilityFocus/index.native.ts index 91605e06243d..2e027c59be39 100644 --- a/src/libs/Accessibility/moveAccessibilityFocus/index.native.js +++ b/src/libs/Accessibility/moveAccessibilityFocus/index.native.ts @@ -1,9 +1,11 @@ import {AccessibilityInfo} from 'react-native'; +import MoveAccessibilityFocus from './types'; -const moveAccessibilityFocus = (ref) => { +const moveAccessibilityFocus: MoveAccessibilityFocus = (ref) => { if (!ref) { return; } + AccessibilityInfo.sendAccessibilityEvent(ref, 'focus'); }; diff --git a/src/libs/Accessibility/moveAccessibilityFocus/index.ts b/src/libs/Accessibility/moveAccessibilityFocus/index.ts new file mode 100644 index 000000000000..b381c1d814c1 --- /dev/null +++ b/src/libs/Accessibility/moveAccessibilityFocus/index.ts @@ -0,0 +1,10 @@ +import MoveAccessibilityFocus from './types'; + +const moveAccessibilityFocus: MoveAccessibilityFocus = (ref) => { + if (!ref?.current) { + return; + } + ref.current.focus(); +}; + +export default moveAccessibilityFocus; diff --git a/src/libs/Accessibility/moveAccessibilityFocus/types.ts b/src/libs/Accessibility/moveAccessibilityFocus/types.ts new file mode 100644 index 000000000000..1344c3f98e3e --- /dev/null +++ b/src/libs/Accessibility/moveAccessibilityFocus/types.ts @@ -0,0 +1,6 @@ +import {ElementRef, RefObject} from 'react'; +import {HostComponent} from 'react-native'; + +type MoveAccessibilityFocus = (ref?: ElementRef> & RefObject) => void; + +export default MoveAccessibilityFocus; diff --git a/src/libs/CardUtils.ts b/src/libs/CardUtils.ts index beb0ea800091..30bb17f3db52 100644 --- a/src/libs/CardUtils.ts +++ b/src/libs/CardUtils.ts @@ -1,5 +1,7 @@ +import lodash from 'lodash'; import {Card} from '../types/onyx'; import CONST from '../CONST'; +import * as OnyxTypes from '../types/onyx'; /** * @returns string with a month in MM format @@ -25,4 +27,33 @@ function getCompanyCards(cardList: {string: Card}) { return Object.values(cardList).filter((card) => card.bank !== CONST.EXPENSIFY_CARD.BANK); } -export {getMonthFromExpirationDateString, getYearFromExpirationDateString, getCompanyCards}; +/** + * @param cardList - collection of assigned cards + * @returns collection of assigned cards grouped by domain + */ +function getDomainCards(cardList: Record) { + // eslint-disable-next-line you-dont-need-lodash-underscore/filter + const activeCards = lodash.filter(cardList, (card) => [2, 3, 4, 7].includes(card.state)); + return lodash.groupBy(activeCards, (card) => card.domainName.toLowerCase()); +} + +/** + * Returns a masked credit card string with spaces for every four symbols. + * If the last four digits are provided, all preceding digits will be masked. + * If not, the entire card string will be masked. + * + * @param [lastFour=""] - The last four digits of the card (optional). + * @returns - The masked card string. + */ +function maskCard(lastFour = ''): string { + const totalDigits = 16; + const maskedLength = totalDigits - lastFour.length; + + // Create a string with '•' repeated for the masked portion + const maskedString = '•'.repeat(maskedLength) + lastFour; + + // Insert space for every four symbols + return maskedString.replace(/(.{4})/g, '$1 ').trim(); +} + +export {getDomainCards, getCompanyCards, getMonthFromExpirationDateString, getYearFromExpirationDateString, maskCard}; diff --git a/src/libs/CurrencyUtils.js b/src/libs/CurrencyUtils.ts similarity index 66% rename from src/libs/CurrencyUtils.js rename to src/libs/CurrencyUtils.ts index 5cf0b22ef337..21784d450a07 100644 --- a/src/libs/CurrencyUtils.js +++ b/src/libs/CurrencyUtils.ts @@ -1,16 +1,15 @@ -import _ from 'underscore'; -import lodashGet from 'lodash/get'; import Onyx from 'react-native-onyx'; -import ONYXKEYS from '../ONYXKEYS'; +import ONYXKEYS, {OnyxValues} from '../ONYXKEYS'; import CONST from '../CONST'; import BaseLocaleListener from './Localize/LocaleListener/BaseLocaleListener'; import * as NumberFormatUtils from './NumberFormatUtils'; -let currencyList = {}; +let currencyList: OnyxValues[typeof ONYXKEYS.CURRENCY_LIST] = {}; + Onyx.connect({ key: ONYXKEYS.CURRENCY_LIST, callback: (val) => { - if (_.isEmpty(val)) { + if (!val || Object.keys(val).length === 0) { return; } @@ -23,56 +22,45 @@ Onyx.connect({ * For currencies that have decimal places > 2, floor to 2 instead: * https://github.com/Expensify/App/issues/15878#issuecomment-1496291464 * - * @param {String} currency - IOU currency - * @returns {Number} + * @param currency - IOU currency */ -function getCurrencyDecimals(currency = CONST.CURRENCY.USD) { - const decimals = lodashGet(currencyList, [currency, 'decimals']); - return _.isUndefined(decimals) ? 2 : decimals; +function getCurrencyDecimals(currency: string = CONST.CURRENCY.USD): number { + const decimals = currencyList?.[currency]?.decimals; + return decimals ?? 2; } /** * Returns the currency's minor unit quantity * e.g. Cent in USD * - * @param {String} currency - IOU currency - * @returns {Number} + * @param currency - IOU currency */ -function getCurrencyUnit(currency = CONST.CURRENCY.USD) { +function getCurrencyUnit(currency: string = CONST.CURRENCY.USD): number { return 10 ** getCurrencyDecimals(currency); } /** * Get localized currency symbol for currency(ISO 4217) Code - * - * @param {String} currencyCode - * @returns {String} */ -function getLocalizedCurrencySymbol(currencyCode) { +function getLocalizedCurrencySymbol(currencyCode: string): string | undefined { const parts = NumberFormatUtils.formatToParts(BaseLocaleListener.getPreferredLocale(), 0, { style: 'currency', currency: currencyCode, }); - return _.find(parts, (part) => part.type === 'currency').value; + return parts.find((part) => part.type === 'currency')?.value; } /** * Get the currency symbol for a currency(ISO 4217) Code - * - * @param {String} currencyCode - * @returns {String|undefined} */ -function getCurrencySymbol(currencyCode) { - return lodashGet(currencyList, [currencyCode, 'symbol']); +function getCurrencySymbol(currencyCode: string): string | undefined { + return currencyList?.[currencyCode]?.symbol; } /** * Whether the currency symbol is left-to-right. - * - * @param {String} currencyCode - * @returns {Boolean} */ -function isCurrencySymbolLTR(currencyCode) { +function isCurrencySymbolLTR(currencyCode: string): boolean { const parts = NumberFormatUtils.formatToParts(BaseLocaleListener.getPreferredLocale(), 0, { style: 'currency', currency: currencyCode, @@ -88,11 +76,8 @@ function isCurrencySymbolLTR(currencyCode) { * when doing math operations. * * @note we do not currently support any currencies with more than two decimal places. Decimal past the second place will be rounded. Sorry Tunisia :( - * - * @param {Number} amountAsFloat - * @returns {Number} */ -function convertToBackendAmount(amountAsFloat) { +function convertToBackendAmount(amountAsFloat: number): number { return Math.round(amountAsFloat * 100); } @@ -100,11 +85,8 @@ function convertToBackendAmount(amountAsFloat) { * Takes an amount in "cents" as an integer and converts it to a floating point amount used in the frontend. * * @note we do not support any currencies with more than two decimal places. - * - * @param {Number} amountAsInt - * @returns {Number} */ -function convertToFrontendAmount(amountAsInt) { +function convertToFrontendAmount(amountAsInt: number): number { return Math.trunc(amountAsInt) / 100.0; } @@ -112,11 +94,10 @@ function convertToFrontendAmount(amountAsInt) { * Given an amount in the "cents", convert it to a string for display in the UI. * The backend always handle things in "cents" (subunit equal to 1/100) * - * @param {Number} amountInCents – should be an integer. Anything after a decimal place will be dropped. - * @param {String} currency - * @returns {String} + * @param amountInCents – should be an integer. Anything after a decimal place will be dropped. + * @param currency - IOU currency */ -function convertToDisplayString(amountInCents, currency = CONST.CURRENCY.USD) { +function convertToDisplayString(amountInCents: number, currency: string = CONST.CURRENCY.USD): string { const convertedAmount = convertToFrontendAmount(amountInCents); return NumberFormatUtils.format(BaseLocaleListener.getPreferredLocale(), convertedAmount, { style: 'currency', @@ -130,12 +111,9 @@ function convertToDisplayString(amountInCents, currency = CONST.CURRENCY.USD) { /** * Checks if passed currency code is a valid currency based on currency list - * - * @param {String} currencyCode - * @returns {Boolean} */ -function isValidCurrencyCode(currencyCode) { - const currency = lodashGet(currencyList, currencyCode); +function isValidCurrencyCode(currencyCode: string): boolean { + const currency = currencyList?.[currencyCode]; return Boolean(currency); } diff --git a/src/libs/DateUtils.js b/src/libs/DateUtils.js index 70c4277bdb5e..a9ec4a3fd35a 100644 --- a/src/libs/DateUtils.js +++ b/src/libs/DateUtils.js @@ -4,11 +4,10 @@ import {es, enGB} from 'date-fns/locale'; import { formatDistanceToNow, subMinutes, + addDays, + subDays, isBefore, subMilliseconds, - isToday, - isTomorrow, - isYesterday, startOfWeek, endOfWeek, format, @@ -85,6 +84,47 @@ function getLocalDateFromDatetime(locale, datetime, currentSelectedTimezone = ti return utcToZonedTime(parsedDatetime, currentSelectedTimezone); } +/** + * Checks if a given date is today in the specified time zone. + * + * @param {Date} date - The date to compare. + * @param {String} timeZone - The time zone to consider. + * @returns {Boolean} True if the date is today; otherwise, false. + */ +function isToday(date, timeZone) { + const currentDate = new Date(); + const currentDateInTimeZone = utcToZonedTime(currentDate, timeZone); + return isSameDay(date, currentDateInTimeZone); +} + +/** + * Checks if a given date is tomorrow in the specified time zone. + * + * @param {Date} date - The date to compare. + * @param {String} timeZone - The time zone to consider. + * @returns {Boolean} True if the date is tomorrow; otherwise, false. + */ +function isTomorrow(date, timeZone) { + const currentDate = new Date(); + const tomorrow = addDays(currentDate, 1); // Get the date for tomorrow in the current time zone + const tomorrowInTimeZone = utcToZonedTime(tomorrow, timeZone); + return isSameDay(date, tomorrowInTimeZone); +} + +/** + * Checks if a given date is yesterday in the specified time zone. + * + * @param {Date} date - The date to compare. + * @param {String} timeZone - The time zone to consider. + * @returns {Boolean} True if the date is yesterday; otherwise, false. + */ +function isYesterday(date, timeZone) { + const currentDate = new Date(); + const yesterday = subDays(currentDate, 1); // Get the date for yesterday in the current time zone + const yesterdayInTimeZone = utcToZonedTime(yesterday, timeZone); + return isSameDay(date, yesterdayInTimeZone); +} + /** * Formats an ISO-formatted datetime string to local date and time string * @@ -117,13 +157,13 @@ function datetimeToCalendarTime(locale, datetime, includeTimeZone = false, curre yesterdayAt = yesterdayAt.toLowerCase(); } - if (isToday(date)) { + if (isToday(date, currentSelectedTimezone)) { return `${todayAt} ${format(date, CONST.DATE.LOCAL_TIME_FORMAT)}${tz}`; } - if (isTomorrow(date)) { + if (isTomorrow(date, currentSelectedTimezone)) { return `${tomorrowAt} ${format(date, CONST.DATE.LOCAL_TIME_FORMAT)}${tz}`; } - if (isYesterday(date)) { + if (isYesterday(date, currentSelectedTimezone)) { return `${yesterdayAt} ${format(date, CONST.DATE.LOCAL_TIME_FORMAT)}${tz}`; } if (date >= startOfCurrentWeek && date <= endOfCurrentWeek) { @@ -346,6 +386,10 @@ const DateUtils = { subtractMillisecondsFromDateTime, getDateStringFromISOTimestamp, getStatusUntilDate, + setLocale, + isToday, + isTomorrow, + isYesterday, }; export default DateUtils; diff --git a/src/libs/ErrorUtils.js b/src/libs/ErrorUtils.js deleted file mode 100644 index 95bbad5f5409..000000000000 --- a/src/libs/ErrorUtils.js +++ /dev/null @@ -1,135 +0,0 @@ -import _ from 'underscore'; -import lodashGet from 'lodash/get'; -import CONST from '../CONST'; -import DateUtils from './DateUtils'; -import * as Localize from './Localize'; - -/** - * @param {Object} response - * @param {Number} response.jsonCode - * @param {String} response.message - * @returns {String} - */ -function getAuthenticateErrorMessage(response) { - switch (response.jsonCode) { - case CONST.JSON_CODE.UNABLE_TO_RETRY: - return 'session.offlineMessageRetry'; - case 401: - return 'passwordForm.error.incorrectLoginOrPassword'; - case 402: - // If too few characters are passed as the password, the WAF will pass it to the API as an empty - // string, which results in a 402 error from Auth. - if (response.message === '402 Missing partnerUserSecret') { - return 'passwordForm.error.incorrectLoginOrPassword'; - } - return 'passwordForm.error.twoFactorAuthenticationEnabled'; - case 403: - if (response.message === 'Invalid code') { - return 'passwordForm.error.incorrect2fa'; - } - return 'passwordForm.error.invalidLoginOrPassword'; - case 404: - return 'passwordForm.error.unableToResetPassword'; - case 405: - return 'passwordForm.error.noAccess'; - case 413: - return 'passwordForm.error.accountLocked'; - default: - return 'passwordForm.error.fallback'; - } -} - -/** - * Method used to get an error object with microsecond as the key. - * @param {String} error - error key or message to be saved - * @return {Object} - * - */ -function getMicroSecondOnyxError(error) { - return {[DateUtils.getMicroseconds()]: error}; -} - -/** - * @param {Object} onyxData - * @param {Object} onyxData.errors - * @returns {String} - */ -function getLatestErrorMessage(onyxData) { - if (_.isEmpty(onyxData.errors)) { - return ''; - } - return _.chain(onyxData.errors || []) - .keys() - .sortBy() - .reverse() - .map((key) => onyxData.errors[key]) - .first() - .value(); -} - -/** - * @param {Object} onyxData - * @param {Object} onyxData.errorFields - * @param {String} fieldName - * @returns {Object} - */ -function getLatestErrorField(onyxData, fieldName) { - const errorsForField = lodashGet(onyxData, ['errorFields', fieldName], {}); - - if (_.isEmpty(errorsForField)) { - return {}; - } - return _.chain(errorsForField) - .keys() - .sortBy() - .reverse() - .map((key) => ({[key]: errorsForField[key]})) - .first() - .value(); -} - -/** - * @param {Object} onyxData - * @param {Object} onyxData.errorFields - * @param {String} fieldName - * @returns {Object} - */ -function getEarliestErrorField(onyxData, fieldName) { - const errorsForField = lodashGet(onyxData, ['errorFields', fieldName], {}); - - if (_.isEmpty(errorsForField)) { - return {}; - } - return _.chain(errorsForField) - .keys() - .sortBy() - .map((key) => ({[key]: errorsForField[key]})) - .first() - .value(); -} - -/** - * Method used to generate error message for given inputID - * @param {Object} errors - An object containing current errors in the form - * @param {String} inputID - * @param {String|Array} message - Message to assign to the inputID errors - * - */ -function addErrorMessage(errors, inputID, message) { - if (!message || !inputID) { - return; - } - - const errorList = errors; - const translatedMessage = Localize.translateIfPhraseKey(message); - - if (_.isEmpty(errorList[inputID])) { - errorList[inputID] = [translatedMessage, {isTranslated: true}]; - } else if (_.isString(errorList[inputID])) { - errorList[inputID] = [`${errorList[inputID]}\n${translatedMessage}`, {isTranslated: true}]; - } else { - errorList[inputID][0] = `${errorList[inputID][0]}\n${translatedMessage}`; - } -} - -export {getAuthenticateErrorMessage, getMicroSecondOnyxError, getLatestErrorMessage, getLatestErrorField, getEarliestErrorField, addErrorMessage}; diff --git a/src/libs/ErrorUtils.ts b/src/libs/ErrorUtils.ts new file mode 100644 index 000000000000..bf4fc0d810a4 --- /dev/null +++ b/src/libs/ErrorUtils.ts @@ -0,0 +1,114 @@ +import CONST from '../CONST'; +import DateUtils from './DateUtils'; +import * as Localize from './Localize'; +import Response from '../types/onyx/Response'; +import {ErrorFields, Errors} from '../types/onyx/OnyxCommon'; +import {TranslationFlatObject} from '../languages/types'; + +function getAuthenticateErrorMessage(response: Response): keyof TranslationFlatObject { + switch (response.jsonCode) { + case CONST.JSON_CODE.UNABLE_TO_RETRY: + return 'session.offlineMessageRetry'; + case 401: + return 'passwordForm.error.incorrectLoginOrPassword'; + case 402: + // If too few characters are passed as the password, the WAF will pass it to the API as an empty + // string, which results in a 402 error from Auth. + if (response.message === '402 Missing partnerUserSecret') { + return 'passwordForm.error.incorrectLoginOrPassword'; + } + return 'passwordForm.error.twoFactorAuthenticationEnabled'; + case 403: + if (response.message === 'Invalid code') { + return 'passwordForm.error.incorrect2fa'; + } + return 'passwordForm.error.invalidLoginOrPassword'; + case 404: + return 'passwordForm.error.unableToResetPassword'; + case 405: + return 'passwordForm.error.noAccess'; + case 413: + return 'passwordForm.error.accountLocked'; + default: + return 'passwordForm.error.fallback'; + } +} + +/** + * Method used to get an error object with microsecond as the key. + * @param error - error key or message to be saved + */ +function getMicroSecondOnyxError(error: string): Record { + return {[DateUtils.getMicroseconds()]: error}; +} + +type OnyxDataWithErrors = { + errors?: Errors; +}; + +function getLatestErrorMessage(onyxData: TOnyxData): string { + const errors = onyxData.errors ?? {}; + + if (Object.keys(errors).length === 0) { + return ''; + } + + const key = Object.keys(errors).sort().reverse()[0]; + + return errors[key]; +} + +type OnyxDataWithErrorFields = { + errorFields?: ErrorFields; +}; + +function getLatestErrorField(onyxData: TOnyxData, fieldName: string): Record { + const errorsForField = onyxData.errorFields?.[fieldName] ?? {}; + + if (Object.keys(errorsForField).length === 0) { + return {}; + } + + const key = Object.keys(errorsForField).sort().reverse()[0]; + + return {[key]: errorsForField[key]}; +} + +function getEarliestErrorField(onyxData: TOnyxData, fieldName: string): Record { + const errorsForField = onyxData.errorFields?.[fieldName] ?? {}; + + if (Object.keys(errorsForField).length === 0) { + return {}; + } + + const key = Object.keys(errorsForField).sort()[0]; + + return {[key]: errorsForField[key]}; +} + +type ErrorsList = Record; + +/** + * Method used to generate error message for given inputID + * @param errorList - An object containing current errors in the form + * @param message - Message to assign to the inputID errors + */ +function addErrorMessage(errors: ErrorsList, inputID?: string, message?: string) { + if (!message || !inputID) { + return; + } + + const errorList = errors; + const error = errorList[inputID]; + const translatedMessage = Localize.translateIfPhraseKey(message); + + if (!error) { + errorList[inputID] = [translatedMessage, {isTranslated: true}]; + } else if (typeof error === 'string') { + errorList[inputID] = [`${error}\n${translatedMessage}`, {isTranslated: true}]; + } else if (Array.isArray(error)) { + error[0] = `${error[0]}\n${translatedMessage}`; + } +} + +export {getAuthenticateErrorMessage, getMicroSecondOnyxError, getLatestErrorMessage, getLatestErrorField, getEarliestErrorField, addErrorMessage}; diff --git a/src/libs/KeyboardShortcut/index.js b/src/libs/KeyboardShortcut/index.js index 37d85c7bfbfc..f91c81a1b856 100644 --- a/src/libs/KeyboardShortcut/index.js +++ b/src/libs/KeyboardShortcut/index.js @@ -83,6 +83,9 @@ _.each(CONST.KEYBOARD_SHORTCUTS, (shortcut) => { */ function unsubscribe(displayName, callbackID) { eventHandlers[displayName] = _.reject(eventHandlers[displayName], (callback) => callback.id === callbackID); + if (_.has(documentedShortcuts, displayName) && _.size(eventHandlers[displayName]) === 0) { + delete documentedShortcuts[displayName]; + } } /** diff --git a/src/libs/Navigation/AppNavigator/AuthScreens.js b/src/libs/Navigation/AppNavigator/AuthScreens.js index 16d0e2225007..428550a43aa8 100644 --- a/src/libs/Navigation/AppNavigator/AuthScreens.js +++ b/src/libs/Navigation/AppNavigator/AuthScreens.js @@ -72,7 +72,10 @@ Onyx.connect({ // then update their timezone. if (_.isObject(timezone) && timezone.automatic && timezone.selected !== currentTimezone) { timezone.selected = currentTimezone; - PersonalDetails.updateAutomaticTimezone(timezone); + PersonalDetails.updateAutomaticTimezone({ + automatic: true, + selected: currentTimezone, + }); } }, }); diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators.js b/src/libs/Navigation/AppNavigator/ModalStackNavigators.js index 71b150680716..6636702592c0 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators.js +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators.js @@ -31,7 +31,6 @@ function createModalStackNavigator(screens) { ); } -// We use getComponent/require syntax so that file used by screens are not loaded until we need them. const MoneyRequestModalStackNavigator = createModalStackNavigator({ Money_Request: () => require('../../../pages/iou/MoneyRequestSelectorPage').default, Money_Request_Amount: () => require('../../../pages/iou/steps/NewRequestAmountPage').default, @@ -46,9 +45,10 @@ const MoneyRequestModalStackNavigator = createModalStackNavigator({ IOU_Send_Add_Bank_Account: () => require('../../../pages/AddPersonalBankAccountPage').default, IOU_Send_Add_Debit_Card: () => require('../../../pages/settings/Wallet/AddDebitCardPage').default, IOU_Send_Enable_Payments: () => require('../../../pages/EnablePayments/EnablePaymentsPage').default, - Money_Request_Waypoint: () => require('../../../pages/iou/WaypointEditorPage').default, + Money_Request_Waypoint: () => require('../../../pages/iou/NewDistanceRequestWaypointEditorPage').default, + Money_Request_Edit_Waypoint: () => require('../../../pages/iou/MoneyRequestEditWaypointPage').default, + Money_Request_Distance: () => require('../../../pages/iou/NewDistanceRequestPage').default, Money_Request_Receipt: () => require('../../../pages/EditRequestReceiptPage').default, - Money_Request_Address: () => require('../../../pages/iou/DistanceRequestPage').default, }); const SplitDetailsModalStackNavigator = createModalStackNavigator({ @@ -126,6 +126,7 @@ const SettingsModalStackNavigator = createModalStackNavigator({ Settings_PersonalDetails_LegalName: () => require('../../../pages/settings/Profile/PersonalDetails/LegalNamePage').default, Settings_PersonalDetails_DateOfBirth: () => require('../../../pages/settings/Profile/PersonalDetails/DateOfBirthPage').default, Settings_PersonalDetails_Address: () => require('../../../pages/settings/Profile/PersonalDetails/AddressPage').default, + Settings_PersonalDetails_Address_Country: () => require('../../../pages/settings/Profile/PersonalDetails/CountrySelectionPage').default, Settings_ContactMethods: () => require('../../../pages/settings/Profile/Contacts/ContactMethodsPage').default, Settings_ContactMethodDetails: () => require('../../../pages/settings/Profile/Contacts/ContactMethodDetailsPage').default, Settings_NewContactMethod: () => require('../../../pages/settings/Profile/Contacts/NewContactMethodPage').default, @@ -140,6 +141,7 @@ const SettingsModalStackNavigator = createModalStackNavigator({ Settings_App_Download_Links: () => require('../../../pages/settings/AppDownloadLinks').default, Settings_Lounge_Access: () => require('../../../pages/settings/Profile/LoungeAccessPage').default, Settings_Wallet: () => require('../../../pages/settings/Wallet/WalletPage').default, + Settings_Wallet_DomainCards: () => require('../../../pages/settings/Wallet/ExpensifyCardPage').default, Settings_Wallet_Transfer_Balance: () => require('../../../pages/settings/Wallet/TransferBalancePage').default, Settings_Wallet_Choose_Transfer_Account: () => require('../../../pages/settings/Wallet/ChooseTransferAccountPage').default, Settings_Wallet_EnablePayments: () => require('../../../pages/EnablePayments/EnablePaymentsPage').default, diff --git a/src/libs/Navigation/AppNavigator/ReportScreenIDSetter.js b/src/libs/Navigation/AppNavigator/ReportScreenIDSetter.js new file mode 100644 index 000000000000..24f855645870 --- /dev/null +++ b/src/libs/Navigation/AppNavigator/ReportScreenIDSetter.js @@ -0,0 +1,121 @@ +import {useEffect} from 'react'; +import PropTypes from 'prop-types'; +import lodashGet from 'lodash/get'; +import {withOnyx} from 'react-native-onyx'; +import ONYXKEYS from '../../../ONYXKEYS'; +import * as ReportUtils from '../../ReportUtils'; +import reportPropTypes from '../../../pages/reportPropTypes'; +import {withNavigationPropTypes} from '../../../components/withNavigation'; +import * as App from '../../actions/App'; +import usePermissions from '../../../hooks/usePermissions'; +import CONST from '../../../CONST'; +import Navigation from '../Navigation'; + +const propTypes = { + /** Available reports that would be displayed in this navigator */ + reports: PropTypes.objectOf(reportPropTypes), + + /** The policies which the user has access to */ + policies: PropTypes.objectOf( + PropTypes.shape({ + /** The policy name */ + name: PropTypes.string, + + /** The type of the policy */ + type: PropTypes.string, + }), + ), + + isFirstTimeNewExpensifyUser: PropTypes.bool, + + /** Navigation route context info provided by react navigation */ + route: PropTypes.shape({ + /** Route specific parameters used on this screen */ + params: PropTypes.shape({ + /** If the admin room should be opened */ + openOnAdminRoom: PropTypes.bool, + + /** The ID of the report this screen should display */ + reportID: PropTypes.string, + }), + }).isRequired, + + ...withNavigationPropTypes, +}; + +const defaultProps = { + reports: {}, + policies: {}, + isFirstTimeNewExpensifyUser: false, +}; + +/** + * Get the most recently accessed report for the user + * + * @param {Object} reports + * @param {Boolean} ignoreDefaultRooms + * @param {Object} policies + * @param {Boolean} isFirstTimeNewExpensifyUser + * @param {Boolean} openOnAdminRoom + * @returns {Number} + */ +const getLastAccessedReportID = (reports, ignoreDefaultRooms, policies, isFirstTimeNewExpensifyUser, openOnAdminRoom) => { + // If deeplink url is of an attachment, we should show the report that the attachment comes from. + const currentRoute = Navigation.getActiveRoute(); + const matches = CONST.REGEX.ATTACHMENT_ROUTE.exec(currentRoute); + const reportID = lodashGet(matches, 1, null); + if (reportID) { + return reportID; + } + + const lastReport = ReportUtils.findLastAccessedReport(reports, ignoreDefaultRooms, policies, isFirstTimeNewExpensifyUser, openOnAdminRoom); + + return lodashGet(lastReport, 'reportID'); +}; + +// This wrapper is reponsible for opening the last accessed report if there is no reportID specified in the route params +function ReportScreenIDSetter({route, reports, policies, isFirstTimeNewExpensifyUser, navigation}) { + const {canUseDefaultRooms} = usePermissions(); + + useEffect(() => { + // Don't update if there is a reportID in the params already + if (lodashGet(route, 'params.reportID', null)) { + App.confirmReadyToOpenApp(); + return; + } + + // If there is no reportID in route, try to find last accessed and use it for setParams + const reportID = getLastAccessedReportID(reports, !canUseDefaultRooms, policies, isFirstTimeNewExpensifyUser, lodashGet(route, 'params.openOnAdminRoom', false)); + + // It's possible that reports aren't fully loaded yet + // in that case the reportID is undefined + if (reportID) { + navigation.setParams({reportID: String(reportID)}); + } else { + App.confirmReadyToOpenApp(); + } + }, [route, navigation, reports, canUseDefaultRooms, policies, isFirstTimeNewExpensifyUser]); + + // The ReportScreen without the reportID set will display a skeleton + // until the reportID is loaded and set in the route param + return null; +} + +ReportScreenIDSetter.propTypes = propTypes; +ReportScreenIDSetter.defaultProps = defaultProps; +ReportScreenIDSetter.displayName = 'ReportScreenIDSetter'; + +export default withOnyx({ + reports: { + key: ONYXKEYS.COLLECTION.REPORT, + allowStaleData: true, + }, + policies: { + key: ONYXKEYS.COLLECTION.POLICY, + allowStaleData: true, + }, + isFirstTimeNewExpensifyUser: { + key: ONYXKEYS.NVP_IS_FIRST_TIME_NEW_EXPENSIFY_USER, + initialValue: false, + }, +})(ReportScreenIDSetter); diff --git a/src/libs/Navigation/AppNavigator/ReportScreenWrapper.js b/src/libs/Navigation/AppNavigator/ReportScreenWrapper.js index f1743e1a2269..767bd9793ac2 100644 --- a/src/libs/Navigation/AppNavigator/ReportScreenWrapper.js +++ b/src/libs/Navigation/AppNavigator/ReportScreenWrapper.js @@ -1,36 +1,10 @@ -import React, {useEffect} from 'react'; import PropTypes from 'prop-types'; -import lodashGet from 'lodash/get'; -import {withOnyx} from 'react-native-onyx'; - -import ONYXKEYS from '../../../ONYXKEYS'; - -import ReportScreen from '../../../pages/home/ReportScreen'; -import * as ReportUtils from '../../ReportUtils'; -import reportPropTypes from '../../../pages/reportPropTypes'; +import React from 'react'; import {withNavigationPropTypes} from '../../../components/withNavigation'; -import * as App from '../../actions/App'; -import usePermissions from '../../../hooks/usePermissions'; -import CONST from '../../../CONST'; -import Navigation from '../Navigation'; +import ReportScreen from '../../../pages/home/ReportScreen'; +import ReportScreenIDSetter from './ReportScreenIDSetter'; const propTypes = { - /** Available reports that would be displayed in this navigator */ - reports: PropTypes.objectOf(reportPropTypes), - - /** The policies which the user has access to */ - policies: PropTypes.objectOf( - PropTypes.shape({ - /** The policy name */ - name: PropTypes.string, - - /** The type of the policy */ - type: PropTypes.string, - }), - ), - - isFirstTimeNewExpensifyUser: PropTypes.bool, - /** Navigation route context info provided by react navigation */ route: PropTypes.shape({ /** Route specific parameters used on this screen */ @@ -46,82 +20,24 @@ const propTypes = { ...withNavigationPropTypes, }; -const defaultProps = { - reports: {}, - policies: {}, - isFirstTimeNewExpensifyUser: false, -}; - -/** - * Get the most recently accessed report for the user - * - * @param {Object} reports - * @param {Boolean} [ignoreDefaultRooms] - * @param {Object} policies - * @param {Boolean} isFirstTimeNewExpensifyUser - * @param {Boolean} openOnAdminRoom - * @returns {Number} - */ -const getLastAccessedReportID = (reports, ignoreDefaultRooms, policies, isFirstTimeNewExpensifyUser, openOnAdminRoom) => { - // If deeplink url is of an attachment, we should show the report that the attachment comes from. - const currentRoute = Navigation.getActiveRoute(); - const matches = CONST.REGEX.ATTACHMENT_ROUTE.exec(currentRoute); - const reportID = lodashGet(matches, 1, null); - if (reportID) { - return reportID; - } - - const lastReport = ReportUtils.findLastAccessedReport(reports, ignoreDefaultRooms, policies, isFirstTimeNewExpensifyUser, openOnAdminRoom); - - return lodashGet(lastReport, 'reportID'); -}; +const defaultProps = {}; -// This wrapper is reponsible for opening the last accessed report if there is no reportID specified in the route params function ReportScreenWrapper(props) { - const {canUseDefaultRooms} = usePermissions(); - - useEffect(() => { - // Don't update if there is a reportID in the params already - if (lodashGet(props.route, 'params.reportID', null)) { - App.confirmReadyToOpenApp(); - return; - } - - // If there is no reportID in route, try to find last accessed and use it for setParams - const reportID = getLastAccessedReportID( - props.reports, - !canUseDefaultRooms, - props.policies, - props.isFirstTimeNewExpensifyUser, - lodashGet(props.route, 'params.openOnAdminRoom', false), - ); - - // It's possible that props.reports aren't fully loaded yet - // in that case the reportID is undefined - if (reportID) { - props.navigation.setParams({reportID: String(reportID)}); - } else { - App.confirmReadyToOpenApp(); - } - }, [props.route, props.navigation, props.reports, canUseDefaultRooms, props.policies, props.isFirstTimeNewExpensifyUser]); - // The ReportScreen without the reportID set will display a skeleton // until the reportID is loaded and set in the route param - return ; + return ( + <> + + + + ); } ReportScreenWrapper.propTypes = propTypes; ReportScreenWrapper.defaultProps = defaultProps; ReportScreenWrapper.displayName = 'ReportScreenWrapper'; -export default withOnyx({ - reports: { - key: ONYXKEYS.COLLECTION.REPORT, - }, - policies: { - key: ONYXKEYS.COLLECTION.POLICY, - }, - isFirstTimeNewExpensifyUser: { - key: ONYXKEYS.NVP_IS_FIRST_TIME_NEW_EXPENSIFY_USER, - }, -})(ReportScreenWrapper); +export default ReportScreenWrapper; diff --git a/src/libs/Navigation/NavigationRoot.js b/src/libs/Navigation/NavigationRoot.js index 4d50a1cd6a68..0dda39517d3c 100644 --- a/src/libs/Navigation/NavigationRoot.js +++ b/src/libs/Navigation/NavigationRoot.js @@ -122,7 +122,10 @@ function NavigationRoot(props) { if (!state) { return; } - updateCurrentReportID(state); + // Performance optimization to avoid context consumers to delay first render + setTimeout(() => { + updateCurrentReportID(state); + }, 0); parseAndLogRoute(state); animateStatusBarBackgroundColor(); }; diff --git a/src/libs/Navigation/linkingConfig.js b/src/libs/Navigation/linkingConfig.js index 8c278e4aad59..bf069aba314e 100644 --- a/src/libs/Navigation/linkingConfig.js +++ b/src/libs/Navigation/linkingConfig.js @@ -69,6 +69,10 @@ export default { path: ROUTES.SETTINGS_WALLET, exact: true, }, + Settings_Wallet_DomainCards: { + path: ROUTES.SETTINGS_WALLET_DOMAINCARDS.route, + exact: true, + }, Settings_Wallet_EnablePayments: { path: ROUTES.SETTINGS_ENABLE_PAYMENTS, exact: true, @@ -147,6 +151,10 @@ export default { path: ROUTES.SETTINGS_PERSONAL_DETAILS_ADDRESS, exact: true, }, + Settings_PersonalDetails_Address_Country: { + path: ROUTES.SETTINGS_PERSONAL_DETAILS_ADDRESS_COUNTRY.route, + exact: true, + }, Settings_TwoFactorAuth: { path: ROUTES.SETTINGS_2FA, exact: true, @@ -259,7 +267,7 @@ export default { }, NewTask: { screens: { - NewTask_Root: ROUTES.NEW_TASK_WITH_REPORT_ID, + NewTask_Root: ROUTES.NEW_TASK, NewTask_TaskAssigneeSelector: ROUTES.NEW_TASK_ASSIGNEE, NewTask_TaskShareDestinationSelector: ROUTES.NEW_TASK_SHARE_DESTINATION, NewTask_Details: ROUTES.NEW_TASK_DETAILS, @@ -325,8 +333,9 @@ export default { Money_Request_Tag: ROUTES.MONEY_REQUEST_TAG.route, Money_Request_Merchant: ROUTES.MONEY_REQUEST_MERCHANT.route, Money_Request_Waypoint: ROUTES.MONEY_REQUEST_WAYPOINT.route, + Money_Request_Edit_Waypoint: ROUTES.MONEY_REQUEST_EDIT_WAYPOINT.route, Money_Request_Receipt: ROUTES.MONEY_REQUEST_RECEIPT.route, - Money_Request_Address: ROUTES.MONEY_REQUEST_ADDRESS.route, + Money_Request_Distance: ROUTES.MONEY_REQUEST_DISTANCE.route, IOU_Send_Enable_Payments: ROUTES.IOU_SEND_ENABLE_PAYMENTS, IOU_Send_Add_Bank_Account: ROUTES.IOU_SEND_ADD_BANK_ACCOUNT, IOU_Send_Add_Debit_Card: ROUTES.IOU_SEND_ADD_DEBIT_CARD, diff --git a/src/libs/Notification/LocalNotification/BrowserNotifications.js b/src/libs/Notification/LocalNotification/BrowserNotifications.js index e55c0430fe17..ca2cfc73866d 100644 --- a/src/libs/Notification/LocalNotification/BrowserNotifications.js +++ b/src/libs/Notification/LocalNotification/BrowserNotifications.js @@ -128,6 +128,16 @@ export default { }); }, + pushModifiedExpenseNotification({reportAction, onClick}, usesIcon = false) { + push({ + title: _.map(reportAction.person, (f) => f.text).join(', '), + body: ReportUtils.getModifiedExpenseMessage(reportAction), + delay: 0, + onClick, + icon: usesIcon ? EXPENSIFY_ICON_URL : '', + }); + }, + /** * Create a notification to indicate that an update is available. */ diff --git a/src/libs/Notification/LocalNotification/index.desktop.js b/src/libs/Notification/LocalNotification/index.desktop.js index 2bef51cea0a6..a70141e7d9db 100644 --- a/src/libs/Notification/LocalNotification/index.desktop.js +++ b/src/libs/Notification/LocalNotification/index.desktop.js @@ -1,5 +1,11 @@ import BrowserNotifications from './BrowserNotifications'; +/** + * @param {Object} options + * @param {Object} options.report + * @param {Object} options.reportAction + * @param {Function} options.onClick + */ function showCommentNotification({report, reportAction, onClick}) { BrowserNotifications.pushReportCommentNotification({report, reportAction, onClick}); } @@ -8,7 +14,18 @@ function showUpdateAvailableNotification() { BrowserNotifications.pushUpdateAvailableNotification(); } +/** + * @param {Object} options + * @param {Object} options.report + * @param {Object} options.reportAction + * @param {Function} options.onClick + */ +function showModifiedExpenseNotification({report, reportAction, onClick}) { + BrowserNotifications.pushModifiedExpenseNotification({report, reportAction, onClick}); +} + export default { showCommentNotification, showUpdateAvailableNotification, + showModifiedExpenseNotification, }; diff --git a/src/libs/Notification/LocalNotification/index.native.js b/src/libs/Notification/LocalNotification/index.native.js index bbc4f48744b6..1f1771e619a5 100644 --- a/src/libs/Notification/LocalNotification/index.native.js +++ b/src/libs/Notification/LocalNotification/index.native.js @@ -2,4 +2,5 @@ export default { showCommentNotification: () => {}, showUpdateAvailableNotification: () => {}, + showModifiedExpenseNotification: () => {}, }; diff --git a/src/libs/Notification/LocalNotification/index.website.js b/src/libs/Notification/LocalNotification/index.website.js index 3410b3144caf..0ecb8bcbb81d 100644 --- a/src/libs/Notification/LocalNotification/index.website.js +++ b/src/libs/Notification/LocalNotification/index.website.js @@ -1,5 +1,11 @@ import BrowserNotifications from './BrowserNotifications'; +/** + * @param {Object} options + * @param {Object} options.report + * @param {Object} options.reportAction + * @param {Function} options.onClick + */ function showCommentNotification({report, reportAction, onClick}) { BrowserNotifications.pushReportCommentNotification({report, reportAction, onClick}, true); } @@ -8,7 +14,18 @@ function showUpdateAvailableNotification() { BrowserNotifications.pushUpdateAvailableNotification(); } +/** + * @param {Object} options + * @param {Object} options.report + * @param {Object} options.reportAction + * @param {Function} options.onClick + */ +function showModifiedExpenseNotification({report, reportAction, onClick}) { + BrowserNotifications.pushModifiedExpenseNotification({report, reportAction, onClick}, true); +} + export default { showCommentNotification, showUpdateAvailableNotification, + showModifiedExpenseNotification, }; diff --git a/src/libs/OptionsListUtils.js b/src/libs/OptionsListUtils.js index 7c36fa095029..e0f334ca36af 100644 --- a/src/libs/OptionsListUtils.js +++ b/src/libs/OptionsListUtils.js @@ -395,7 +395,8 @@ function getLastMessageTextForReport(report) { const iouReport = ReportUtils.getReport(ReportActionUtils.getIOUReportIDFromReportActionPreview(lastReportAction)); lastMessageTextFromReport = ReportUtils.getReportPreviewMessage(iouReport, lastReportAction); } else if (ReportActionUtils.isModifiedExpenseAction(lastReportAction)) { - lastMessageTextFromReport = ReportUtils.getModifiedExpenseMessage(lastReportAction); + const properSchemaForModifiedExpenseMessage = ReportUtils.getModifiedExpenseMessage(lastReportAction); + lastMessageTextFromReport = ReportUtils.formatReportLastMessageText(properSchemaForModifiedExpenseMessage, true); } else { lastMessageTextFromReport = report ? report.lastMessageText || '' : ''; @@ -814,6 +815,24 @@ function getTagListSections(tags, recentlyUsedTags, selectedOptions, searchInput const numberOfTags = _.size(enabledTags); let indexOffset = 0; + // If all tags are disabled but there's a previously selected tag, show only the selected tag + if (numberOfTags === 0 && selectedOptions.length > 0) { + const selectedTagOptions = _.map(selectedOptions, (option) => ({ + name: option.name, + // Should be marked as enabled to be able to be de-selected + enabled: true, + })); + tagSections.push({ + // "Selected" section + title: '', + shouldShow: false, + indexOffset, + data: getTagsOptions(selectedTagOptions), + }); + + return tagSections; + } + if (!_.isEmpty(searchInputValue)) { const searchTags = _.filter(enabledTags, (tag) => tag.name.toLowerCase().includes(searchInputValue.toLowerCase())); diff --git a/src/libs/PolicyUtils.js b/src/libs/PolicyUtils.js index 1f6e8a9bc1b5..347a825f59cc 100644 --- a/src/libs/PolicyUtils.js +++ b/src/libs/PolicyUtils.js @@ -203,6 +203,56 @@ function getIneligibleInvitees(policyMembers, personalDetails) { return memberEmailsToExclude; } +/** + * Gets the tag from policy tags, defaults to the first if no key is provided. + * + * @param {Object} policyTags + * @param {String} [tagKey] + * @returns {Object} + */ +function getTag(policyTags, tagKey) { + if (_.isEmpty(policyTags)) { + return {}; + } + + const policyTagKey = tagKey || _.first(_.keys(policyTags)); + + return lodashGet(policyTags, policyTagKey, {}); +} + +/** + * Gets the first tag name from policy tags. + * + * @param {Object} policyTags + * @returns {String} + */ +function getTagListName(policyTags) { + if (_.isEmpty(policyTags)) { + return ''; + } + + const policyTagKeys = _.keys(policyTags) || []; + + return lodashGet(policyTags, [_.first(policyTagKeys), 'name'], ''); +} + +/** + * Gets the tags of a policy for a specific key. Defaults to the first tag if no key is provided. + * + * @param {Object} policyTags + * @param {String} [tagKey] + * @returns {String} + */ +function getTagList(policyTags, tagKey) { + if (_.isEmpty(policyTags)) { + return {}; + } + + const policyTagKey = tagKey || _.first(_.keys(policyTags)); + + return lodashGet(policyTags, [policyTagKey, 'tags'], {}); +} + /** * @param {Object} policy * @returns {Boolean} @@ -226,5 +276,8 @@ export { isPolicyAdmin, getMemberAccountIDsForWorkspace, getIneligibleInvitees, + getTag, + getTagListName, + getTagList, isPendingDeletePolicy, }; diff --git a/src/libs/PusherConnectionManager.js b/src/libs/PusherConnectionManager.ts similarity index 83% rename from src/libs/PusherConnectionManager.js rename to src/libs/PusherConnectionManager.ts index a391a4973fd4..4ab08d6dc760 100644 --- a/src/libs/PusherConnectionManager.js +++ b/src/libs/PusherConnectionManager.ts @@ -1,9 +1,12 @@ -import lodashGet from 'lodash/get'; +import {ValueOf} from 'type-fest'; import * as Pusher from './Pusher/pusher'; import * as Session from './actions/Session'; import Log from './Log'; import CONST from '../CONST'; +type EventCallbackError = {type: ValueOf; data: {code: number}}; +type CustomAuthorizerChannel = {name: string}; + function init() { /** * When authTokens expire they will automatically be refreshed. @@ -11,20 +14,17 @@ function init() { * current valid token to generate the signed auth response * needed to subscribe to Pusher channels. */ - Pusher.registerCustomAuthorizer((channel) => ({ - authorize: (socketID, callback) => { + Pusher.registerCustomAuthorizer((channel: CustomAuthorizerChannel) => ({ + authorize: (socketID: string, callback: () => void) => { Session.authenticatePusher(socketID, channel.name, callback); }, })); - /** - * @params {string} eventName - */ - Pusher.registerSocketEventCallback((eventName, error) => { + Pusher.registerSocketEventCallback((eventName: string, error: EventCallbackError) => { switch (eventName) { case 'error': { - const errorType = lodashGet(error, 'type'); - const code = lodashGet(error, 'data.code'); + const errorType = error?.type; + const code = error?.data?.code; if (errorType === CONST.ERROR.PUSHER_ERROR && code === 1006) { // 1006 code happens when a websocket connection is closed. There may or may not be a reason attached indicating why the connection was closed. // https://datatracker.ietf.org/doc/html/rfc6455#section-7.1.5 diff --git a/src/libs/ReceiptUtils.js b/src/libs/ReceiptUtils.ts similarity index 69% rename from src/libs/ReceiptUtils.js rename to src/libs/ReceiptUtils.ts index 8f352c182171..cdc45cb119d5 100644 --- a/src/libs/ReceiptUtils.js +++ b/src/libs/ReceiptUtils.ts @@ -1,4 +1,5 @@ import Str from 'expensify-common/lib/str'; +import {ImageSourcePropType} from 'react-native'; import * as FileUtils from './fileDownload/FileUtils'; import CONST from '../CONST'; import ReceiptHTML from '../../assets/images/receipt-html.png'; @@ -6,14 +7,23 @@ import ReceiptDoc from '../../assets/images/receipt-doc.png'; import ReceiptGeneric from '../../assets/images/receipt-generic.png'; import ReceiptSVG from '../../assets/images/receipt-svg.png'; +type ThumbnailAndImageURI = { + image: ImageSourcePropType | string; + thumbnail: string | null; +}; + +type FileNameAndExtension = { + fileExtension?: string; + fileName?: string; +}; + /** * Grab the appropriate receipt image and thumbnail URIs based on file type * - * @param {String} path URI to image, i.e. blob:new.expensify.com/9ef3a018-4067-47c6-b29f-5f1bd35f213d or expensify.com/receipts/w_e616108497ef940b7210ec6beb5a462d01a878f4.jpg - * @param {String} filename of uploaded image or last part of remote URI - * @returns {Object} + * @param path URI to image, i.e. blob:new.expensify.com/9ef3a018-4067-47c6-b29f-5f1bd35f213d or expensify.com/receipts/w_e616108497ef940b7210ec6beb5a462d01a878f4.jpg + * @param filename of uploaded image or last part of remote URI */ -function getThumbnailAndImageURIs(path, filename) { +function getThumbnailAndImageURIs(path: string, filename: string): ThumbnailAndImageURI { const isReceiptImage = Str.isImage(filename); // For local files, we won't have a thumbnail yet @@ -25,7 +35,7 @@ function getThumbnailAndImageURIs(path, filename) { return {thumbnail: `${path}.1024.jpg`, image: path}; } - const {fileExtension} = FileUtils.splitExtensionFromFileName(filename); + const {fileExtension} = FileUtils.splitExtensionFromFileName(filename) as FileNameAndExtension; let image = ReceiptGeneric; if (fileExtension === CONST.IOU.FILE_TYPES.HTML) { image = ReceiptHTML; @@ -38,6 +48,7 @@ function getThumbnailAndImageURIs(path, filename) { if (fileExtension === CONST.IOU.FILE_TYPES.SVG) { image = ReceiptSVG; } + return {thumbnail: null, image}; } diff --git a/src/libs/ReportActionsUtils.js b/src/libs/ReportActionsUtils.js index 19b0513b7e41..67c44784eeb2 100644 --- a/src/libs/ReportActionsUtils.js +++ b/src/libs/ReportActionsUtils.js @@ -652,6 +652,11 @@ function isReportActionAttachment(reportAction) { return _.has(reportAction, 'isAttachment') ? reportAction.isAttachment : isReportMessageAttachment(message); } +// eslint-disable-next-line rulesdir/no-negated-variables +function isNotifiableReportAction(reportAction) { + return reportAction && _.contains([CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, CONST.REPORT.ACTIONS.TYPE.IOU, CONST.REPORT.ACTIONS.TYPE.MODIFIEDEXPENSE], reportAction.actionName); +} + export { getSortedReportActions, getLastVisibleAction, @@ -690,4 +695,5 @@ export { isTaskAction, getAllReportActions, isReportActionAttachment, + isNotifiableReportAction, }; diff --git a/src/libs/ReportUtils.js b/src/libs/ReportUtils.js index 1d0f676e4116..42e7e40fe62b 100644 --- a/src/libs/ReportUtils.js +++ b/src/libs/ReportUtils.js @@ -903,9 +903,13 @@ function canShowReportRecipientLocalTime(personalDetails, report, accountID) { /** * Shorten last message text to fixed length and trim spaces. * @param {String} lastMessageText + * @param {Boolean} isModifiedExpenseMessage * @returns {String} */ -function formatReportLastMessageText(lastMessageText) { +function formatReportLastMessageText(lastMessageText, isModifiedExpenseMessage = false) { + if (isModifiedExpenseMessage) { + return String(lastMessageText).trim().replace(CONST.REGEX.LINE_BREAK, '').trim(); + } return String(lastMessageText).trim().replace(CONST.REGEX.AFTER_FIRST_LINE_BREAK, '').substring(0, CONST.REPORT.LAST_MESSAGE_TEXT_MAX_LENGTH).trim(); } @@ -949,7 +953,7 @@ function getIconsForParticipants(participants, personalDetails) { const accountID = participantsList[i]; const avatarSource = UserUtils.getAvatar(lodashGet(personalDetails, [accountID, 'avatar'], ''), accountID); const displayNameLogin = lodashGet(personalDetails, [accountID, 'displayName']) || lodashGet(personalDetails, [accountID, 'login'], ''); - participantDetails.push([accountID, displayNameLogin, avatarSource]); + participantDetails.push([accountID, displayNameLogin, avatarSource, lodashGet(personalDetails, [accountID, 'fallBackIcon'])]); } const sortedParticipantDetails = _.chain(participantDetails) @@ -975,6 +979,7 @@ function getIconsForParticipants(participants, personalDetails) { source: sortedParticipantDetails[i][2], type: CONST.ICON_TYPE_AVATAR, name: sortedParticipantDetails[i][1], + fallBackIcon: sortedParticipantDetails[i][3], }; avatars.push(userIcon); } @@ -1031,6 +1036,7 @@ function getIcons(report, personalDetails, defaultIcon = null, defaultName = '', id: parentReportAction.actorAccountID, type: CONST.ICON_TYPE_AVATAR, name: lodashGet(personalDetails, [parentReportAction.actorAccountID, 'displayName'], ''), + fallbackIcon: lodashGet(personalDetails, [parentReportAction.actorAccountID, 'fallbackIcon']), }; return [memberIcon, workspaceIcon]; @@ -1045,6 +1051,7 @@ function getIcons(report, personalDetails, defaultIcon = null, defaultName = '', source: UserUtils.getAvatar(lodashGet(personalDetails, [actorAccountID, 'avatar']), actorAccountID), name: actorDisplayName, type: CONST.ICON_TYPE_AVATAR, + fallbackIcon: lodashGet(personalDetails, [parentReportAction.actorAccountID, 'fallbackIcon']), }; if (isWorkspaceThread(report)) { @@ -1059,6 +1066,7 @@ function getIcons(report, personalDetails, defaultIcon = null, defaultName = '', source: UserUtils.getAvatar(lodashGet(personalDetails, [report.ownerAccountID, 'avatar']), report.ownerAccountID), type: CONST.ICON_TYPE_AVATAR, name: lodashGet(personalDetails, [report.ownerAccountID, 'displayName'], ''), + fallbackIcon: lodashGet(personalDetails, [report.ownerAccountID, 'fallbackIcon']), }; if (isWorkspaceTaskReport(report)) { @@ -1091,6 +1099,7 @@ function getIcons(report, personalDetails, defaultIcon = null, defaultName = '', id: report.ownerAccountID, type: CONST.ICON_TYPE_AVATAR, name: lodashGet(personalDetails, [report.ownerAccountID, 'displayName'], ''), + fallbackIcon: lodashGet(personalDetails, [report.ownerAccountID, 'fallbackIcon']), }; return isExpenseReport(report) ? [memberIcon, workspaceIcon] : [workspaceIcon, memberIcon]; } @@ -1100,12 +1109,14 @@ function getIcons(report, personalDetails, defaultIcon = null, defaultName = '', id: report.managerID, type: CONST.ICON_TYPE_AVATAR, name: lodashGet(personalDetails, [report.managerID, 'displayName'], ''), + fallbackIcon: lodashGet(personalDetails, [report.managerID, 'fallbackIcon']), }; const ownerIcon = { id: report.ownerAccountID, source: UserUtils.getAvatar(lodashGet(personalDetails, [report.ownerAccountID, 'avatar']), report.ownerAccountID), type: CONST.ICON_TYPE_AVATAR, name: lodashGet(personalDetails, [report.ownerAccountID, 'displayName'], ''), + fallbackIcon: lodashGet(personalDetails, [report.ownerAccountID, 'fallbackIcon']), }; const isPayer = currentUserAccountID === report.managerID; @@ -1238,8 +1249,15 @@ function isWaitingForIOUActionFromCurrentUser(report) { return false; } -function isWaitingForTaskCompleteFromAssignee(report) { - return isTaskReport(report) && isReportManager(report) && isOpenTaskReport(report); +/** + * Checks if a report is an open task report assigned to current user. + * + * @param {Object} report + * @param {Object} parentReportAction - The parent report action of the report (Used to check if the task has been canceled) + * @returns {Boolean} + */ +function isWaitingForTaskCompleteFromAssignee(report, parentReportAction = {}) { + return isTaskReport(report) && isReportManager(report) && isOpenTaskReport(report, parentReportAction); } /** @@ -1327,7 +1345,8 @@ function getMoneyRequestReportName(report, policy = undefined) { } /** - * Gets transaction created, amount, currency and comment + * Gets transaction created, amount, currency, comment, and waypoints (for distance request) + * into a flat object. Used for displaying transactions and sending them in API commands * * @param {Object} transaction * @returns {Object} @@ -1340,7 +1359,10 @@ function getTransactionDetails(transaction) { currency: TransactionUtils.getCurrency(transaction), comment: TransactionUtils.getDescription(transaction), merchant: TransactionUtils.getMerchant(transaction), + waypoints: TransactionUtils.getWaypoints(transaction), category: TransactionUtils.getCategory(transaction), + billable: TransactionUtils.getBillable(transaction), + tag: TransactionUtils.getTag(transaction), }; } @@ -1546,6 +1568,9 @@ function getProperSchemaForModifiedExpenseMessage(newValue, oldValue, valueName, /** * Get the report action message when expense has been modified. * + * ModifiedExpense::getNewDotComment in Web-Expensify should match this. + * If we change this function be sure to update the backend as well. + * * @param {Object} reportAction * @returns {String} */ @@ -1592,6 +1617,16 @@ function getModifiedExpenseMessage(reportAction) { if (hasModifiedCategory) { return getProperSchemaForModifiedExpenseMessage(reportActionOriginalMessage.category, reportActionOriginalMessage.oldCategory, Localize.translateLocal('common.category'), true); } + + const hasModifiedTag = _.has(reportActionOriginalMessage, 'oldTag') && _.has(reportActionOriginalMessage, 'tag'); + if (hasModifiedTag) { + return getProperSchemaForModifiedExpenseMessage(reportActionOriginalMessage.tag, reportActionOriginalMessage.oldTag, Localize.translateLocal('common.tag'), true); + } + + const hasModifiedBillable = _.has(reportActionOriginalMessage, 'oldBillable') && _.has(reportActionOriginalMessage, 'billable'); + if (hasModifiedBillable) { + return getProperSchemaForModifiedExpenseMessage(reportActionOriginalMessage.billable, reportActionOriginalMessage.oldBillable, Localize.translateLocal('iou.request'), true); + } } /** @@ -1637,6 +1672,17 @@ function getModifiedExpenseOriginalMessage(oldTransaction, transactionChanges, i originalMessage.category = transactionChanges.category; } + if (_.has(transactionChanges, 'tag')) { + originalMessage.oldTag = TransactionUtils.getTag(oldTransaction); + originalMessage.tag = transactionChanges.tag; + } + + if (_.has(transactionChanges, 'billable')) { + const oldBillable = TransactionUtils.getBillable(oldTransaction); + originalMessage.oldBillable = oldBillable ? Localize.translateLocal('common.billable').toLowerCase() : Localize.translateLocal('common.nonBillable').toLowerCase(); + originalMessage.billable = transactionChanges.billable ? Localize.translateLocal('common.billable').toLowerCase() : Localize.translateLocal('common.nonBillable').toLowerCase(); + } + return originalMessage; } @@ -2505,7 +2551,7 @@ function buildOptimisticCreatedReportAction(emailCreatingAction) { { type: CONST.REPORT.MESSAGE.TYPE.TEXT, style: 'strong', - text: emailCreatingAction === currentUserEmail ? 'You' : emailCreatingAction, + text: emailCreatingAction, }, { type: CONST.REPORT.MESSAGE.TYPE.TEXT, @@ -2544,7 +2590,7 @@ function buildOptimisticEditedTaskReportAction(emailEditingTask) { { type: CONST.REPORT.MESSAGE.TYPE.TEXT, style: 'strong', - text: emailEditingTask === currentUserEmail ? 'You' : emailEditingTask, + text: emailEditingTask, }, { type: CONST.REPORT.MESSAGE.TYPE.TEXT, @@ -2585,7 +2631,7 @@ function buildOptimisticClosedReportAction(emailClosingReport, policyName, reaso { type: CONST.REPORT.MESSAGE.TYPE.TEXT, style: 'strong', - text: emailClosingReport === currentUserEmail ? 'You' : emailClosingReport, + text: emailClosingReport, }, { type: CONST.REPORT.MESSAGE.TYPE.TEXT, @@ -3538,8 +3584,8 @@ 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) { - optimisticAssigneeAddComment = buildOptimisticTaskCommentReportAction(taskReportID, title, assigneeEmail, assigneeAccountID, `Assigned a task to you: ${title}`, parentReportID); - + const displayname = lodashGet(allPersonalDetails, [assigneeAccountID, 'displayName']) || lodashGet(allPersonalDetails, [assigneeAccountID, 'login'], ''); + optimisticAssigneeAddComment = buildOptimisticTaskCommentReportAction(taskReportID, title, assigneeEmail, assigneeAccountID, `assigned to ${displayname}`, parentReportID); const lastAssigneeCommentText = formatReportLastMessageText(optimisticAssigneeAddComment.reportAction.message[0].text); const optimisticAssigneeReport = { lastVisibleActionCreated: currentTime, @@ -3597,6 +3643,46 @@ function getReportPreviewDisplayTransactions(reportPreviewAction) { ); } +/** + * Return iou report action display message + * + * @param {Object} reportAction report action + * @returns {String} + */ +function getIOUReportActionDisplayMessage(reportAction) { + const originalMessage = _.get(reportAction, 'originalMessage', {}); + let displayMessage; + if (originalMessage.type === CONST.IOU.REPORT_ACTION_TYPE.PAY) { + const {amount, currency, IOUReportID} = originalMessage; + const formattedAmount = CurrencyUtils.convertToDisplayString(amount, currency); + const iouReport = getReport(IOUReportID); + const payerName = isExpenseReport(iouReport) ? getPolicyName(iouReport) : getDisplayNameForParticipant(iouReport.managerID); + let translationKey; + switch (originalMessage.paymentType) { + case CONST.IOU.PAYMENT_TYPE.ELSEWHERE: + translationKey = 'iou.paidElsewhereWithAmount'; + break; + case CONST.IOU.PAYMENT_TYPE.EXPENSIFY: + case CONST.IOU.PAYMENT_TYPE.VBBA: + translationKey = 'iou.paidUsingExpensifyWithAmount'; + break; + default: + translationKey = ''; + break; + } + displayMessage = Localize.translateLocal(translationKey, {amount: formattedAmount, payer: payerName}); + } else { + const transaction = TransactionUtils.getTransaction(originalMessage.IOUTransactionID); + const {amount, currency, comment} = getTransactionDetails(transaction); + const formattedAmount = CurrencyUtils.convertToDisplayString(amount, currency); + displayMessage = Localize.translateLocal('iou.requestedAmount', { + formattedAmount, + comment, + }); + } + return displayMessage; +} + export { getReportParticipantsTitle, isReportMessageAttachment, @@ -3737,4 +3823,6 @@ export { getReportPreviewDisplayTransactions, getTransactionsWithReceipts, hasMissingSmartscanFields, + getIOUReportActionDisplayMessage, + isWaitingForTaskCompleteFromAssignee, }; diff --git a/src/libs/SidebarUtils.js b/src/libs/SidebarUtils.js index 965d969c1332..7a32db660021 100644 --- a/src/libs/SidebarUtils.js +++ b/src/libs/SidebarUtils.js @@ -71,6 +71,16 @@ function isSidebarLoadedReady() { return sidebarIsReadyPromise; } +function compareStringDates(stringA, stringB) { + if (stringA < stringB) { + return -1; + } + if (stringA > stringB) { + return 1; + } + return 0; +} + function setIsSidebarLoadedReady() { resolveSidebarIsReadyPromise(); } @@ -184,12 +194,13 @@ function getOrderedReportIDs(currentReportId, allReportsDict, betas, policies, p pinnedReports.sort((a, b) => a.displayName.toLowerCase().localeCompare(b.displayName.toLowerCase())); outstandingIOUReports.sort((a, b) => b.iouReportAmount - a.iouReportAmount || a.displayName.toLowerCase().localeCompare(b.displayName.toLowerCase())); draftReports.sort((a, b) => a.displayName.toLowerCase().localeCompare(b.displayName.toLowerCase())); + if (isInDefaultMode) { nonArchivedReports.sort( - (a, b) => new Date(b.lastVisibleActionCreated) - new Date(a.lastVisibleActionCreated) || a.displayName.toLowerCase().localeCompare(b.displayName.toLowerCase()), + (a, b) => compareStringDates(b.lastVisibleActionCreated, a.lastVisibleActionCreated) || a.displayName.toLowerCase().localeCompare(b.displayName.toLowerCase()), ); // For archived reports ensure that most recent reports are at the top by reversing the order - archivedReports.sort((a, b) => new Date(a.lastVisibleActionCreated) - new Date(b.lastVisibleActionCreated)); + archivedReports.sort((a, b) => compareStringDates(b.lastVisibleActionCreated, a.lastVisibleActionCreated)); } else { nonArchivedReports.sort((a, b) => a.displayName.toLowerCase().localeCompare(b.displayName.toLowerCase())); archivedReports.sort((a, b) => a.displayName.toLowerCase().localeCompare(b.displayName.toLowerCase())); @@ -210,9 +221,10 @@ function getOrderedReportIDs(currentReportId, allReportsDict, betas, policies, p * @param {Object} personalDetails * @param {String} preferredLocale * @param {Object} [policy] + * @param {Object} parentReportAction * @returns {Object} */ -function getOptionData(report, reportActions, personalDetails, preferredLocale, policy) { +function getOptionData(report, reportActions, personalDetails, preferredLocale, policy, parentReportAction) { // When a user signs out, Onyx is cleared. Due to the lazy rendering with a virtual list, it's possible for // this method to be called after the Onyx data has been cleared out. In that case, it's fine to do // a null check here and return early. @@ -266,8 +278,7 @@ function getOptionData(report, reportActions, personalDetails, preferredLocale, result.isChatRoom = ReportUtils.isChatRoom(report); result.isTaskReport = ReportUtils.isTaskReport(report); if (result.isTaskReport) { - result.isCompletedTaskReport = ReportUtils.isCompletedTaskReport(report); - result.isTaskAssignee = ReportUtils.isReportManager(report); + result.isWaitingForTaskCompleteFromAssignee = ReportUtils.isWaitingForTaskCompleteFromAssignee(report, parentReportAction); } result.isArchivedRoom = ReportUtils.isArchivedRoom(report); result.isPolicyExpenseChat = ReportUtils.isPolicyExpenseChat(report); @@ -320,9 +331,9 @@ function getOptionData(report, reportActions, personalDetails, preferredLocale, } : null; } - let lastMessageText = - hasMultipleParticipants && lastActorDetails && lastActorDetails.accountID && Number(lastActorDetails.accountID) !== currentUserAccountID ? `${lastActorDetails.displayName}: ` : ''; - lastMessageText += report ? lastMessageTextFromReport : ''; + const lastActorDisplayName = + hasMultipleParticipants && lastActorDetails && lastActorDetails.accountID && Number(lastActorDetails.accountID) !== currentUserAccountID ? lastActorDetails.displayName : ''; + let lastMessageText = lastMessageTextFromReport; if (result.isArchivedRoom) { const archiveReason = @@ -340,9 +351,11 @@ function getOptionData(report, reportActions, personalDetails, preferredLocale, const newName = lodashGet(lastAction, 'originalMessage.newName', ''); result.alternateText = Localize.translate(preferredLocale, 'newRoomPage.roomRenamedTo', {newName}); } else if (lodashGet(lastAction, 'actionName', '') === CONST.REPORT.ACTIONS.TYPE.TASKREOPENED) { - result.alternateText = `${Localize.translate(preferredLocale, 'task.messages.reopened')}: ${report.reportName}`; + result.alternateText = `${Localize.translate(preferredLocale, 'task.messages.reopened')}`; } else if (lodashGet(lastAction, 'actionName', '') === CONST.REPORT.ACTIONS.TYPE.TASKCOMPLETED) { - result.alternateText = `${Localize.translate(preferredLocale, 'task.messages.completed')}: ${report.reportName}`; + result.alternateText = `${Localize.translate(preferredLocale, 'task.messages.completed')}`; + } else if (lodashGet(lastAction, 'actionName', '') !== CONST.REPORT.ACTIONS.TYPE.REPORTPREVIEW && lastActorDisplayName && lastMessageTextFromReport) { + result.alternateText = `${lastActorDisplayName}: ${lastMessageText}`; } else { result.alternateText = lastMessageTextFromReport.length > 0 ? lastMessageText : Localize.translate(preferredLocale, 'report.noActivityYet'); } diff --git a/src/libs/TransactionUtils.js b/src/libs/TransactionUtils.js index 5dcfbc467c20..aff1068546d1 100644 --- a/src/libs/TransactionUtils.js +++ b/src/libs/TransactionUtils.js @@ -147,10 +147,23 @@ function getUpdatedTransaction(transaction, transactionChanges, isFromExpenseRep shouldStopSmartscan = true; } + if (_.has(transactionChanges, 'waypoints')) { + updatedTransaction.modifiedWaypoints = transactionChanges.waypoints; + shouldStopSmartscan = true; + } + + if (_.has(transactionChanges, 'billable')) { + updatedTransaction.billable = transactionChanges.billable; + } + if (_.has(transactionChanges, 'category')) { updatedTransaction.category = transactionChanges.category; } + if (_.has(transactionChanges, 'tag')) { + updatedTransaction.tag = transactionChanges.tag; + } + if (shouldStopSmartscan && _.has(transaction, 'receipt') && !_.isEmpty(transaction.receipt) && lodashGet(transaction, 'receipt.state') !== CONST.IOU.RECEIPT_STATE.OPEN) { updatedTransaction.receipt.state = CONST.IOU.RECEIPT_STATE.OPEN; } @@ -161,7 +174,10 @@ function getUpdatedTransaction(transaction, transactionChanges, isFromExpenseRep ...(_.has(transactionChanges, 'amount') && {amount: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}), ...(_.has(transactionChanges, 'currency') && {currency: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}), ...(_.has(transactionChanges, 'merchant') && {merchant: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}), + ...(_.has(transactionChanges, 'waypoints') && {waypoints: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}), + ...(_.has(transactionChanges, 'billable') && {billable: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}), ...(_.has(transactionChanges, 'category') && {category: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}), + ...(_.has(transactionChanges, 'tag') && {tag: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}), }; return updatedTransaction; @@ -243,6 +259,16 @@ function getMerchant(transaction) { return lodashGet(transaction, 'modifiedMerchant', null) || lodashGet(transaction, 'merchant', ''); } +/** + * Return the waypoints field from the transaction, return the modifiedWaypoints if present. + * + * @param {Object} transaction + * @returns {String} + */ +function getWaypoints(transaction) { + return lodashGet(transaction, 'modifiedWaypoints', null) || lodashGet(transaction, ['comment', 'waypoints']); +} + /** * Return the category from the transaction. This "category" field has no "modified" complement. * @@ -253,6 +279,26 @@ function getCategory(transaction) { return lodashGet(transaction, 'category', ''); } +/** + * Return the billable field from the transaction. This "billable" field has no "modified" complement. + * + * @param {Object} transaction + * @return {Boolean} + */ +function getBillable(transaction) { + return lodashGet(transaction, 'billable', false); +} + +/** + * Return the tag from the transaction. This "tag" field has no "modified" complement. + * + * @param {Object} transaction + * @return {String} + */ +function getTag(transaction) { + return lodashGet(transaction, 'tag', ''); +} + /** * Return the created field from the transaction, return the modifiedCreated if present. * @@ -399,6 +445,8 @@ export { getMerchant, getCreated, getCategory, + getBillable, + getTag, getLinkedTransaction, getAllReportTransactions, hasReceipt, @@ -406,6 +454,7 @@ export { isReceiptBeingScanned, getValidWaypoints, isDistanceRequest, + getWaypoints, hasMissingSmartscanFields, getWaypointIndex, waypointHasValidAddress, diff --git a/src/libs/ValidationUtils.js b/src/libs/ValidationUtils.ts similarity index 65% rename from src/libs/ValidationUtils.js rename to src/libs/ValidationUtils.ts index a85a623bd3ec..80b15690ac46 100644 --- a/src/libs/ValidationUtils.js +++ b/src/libs/ValidationUtils.ts @@ -1,22 +1,23 @@ import {subYears, addYears, startOfDay, endOfMonth, parse, isAfter, isBefore, isValid, isWithinInterval, isSameDay, format} from 'date-fns'; -import _ from 'underscore'; import {URL_REGEX_WITH_REQUIRED_PROTOCOL} from 'expensify-common/lib/Url'; import {parsePhoneNumber} from 'awesome-phonenumber'; +import isDate from 'lodash/isDate'; +import isEmpty from 'lodash/isEmpty'; +import isObject from 'lodash/isObject'; import CONST from '../CONST'; import * as CardUtils from './CardUtils'; import * as LoginUtils from './LoginUtils'; +import {Report} from '../types/onyx'; +import * as OnyxCommon from '../types/onyx/OnyxCommon'; /** * Implements the Luhn Algorithm, a checksum formula used to validate credit card * numbers. - * - * @param {String} val - * @returns {Boolean} */ -function validateCardNumber(val) { +function validateCardNumber(value: string): boolean { let sum = 0; - for (let i = 0; i < val.length; i++) { - let intVal = parseInt(val.substr(i, 1), 10); + for (let i = 0; i < value.length; i++) { + let intVal = parseInt(value.substr(i, 1), 10); if (i % 2 === 0) { intVal *= 2; if (intVal > 9) { @@ -30,11 +31,8 @@ function validateCardNumber(val) { /** * Validating that this is a valid address (PO boxes are not allowed) - * - * @param {String} value - * @returns {Boolean} */ -function isValidAddress(value) { +function isValidAddress(value: string): boolean { if (!CONST.REGEX.ANY_VALUE.test(value)) { return false; } @@ -44,11 +42,8 @@ function isValidAddress(value) { /** * Validate date fields - * - * @param {String|Date} date - * @returns {Boolean} true if valid */ -function isValidDate(date) { +function isValidDate(date: string | Date): boolean { if (!date) { return false; } @@ -61,11 +56,8 @@ function isValidDate(date) { /** * Validate that date entered isn't a future date. - * - * @param {String|Date} date - * @returns {Boolean} true if valid */ -function isValidPastDate(date) { +function isValidPastDate(date: string | Date): boolean { if (!date) { return false; } @@ -78,33 +70,27 @@ function isValidPastDate(date) { /** * Used to validate a value that is "required". - * - * @param {*} value - * @returns {Boolean} */ -function isRequiredFulfilled(value) { - if (_.isString(value)) { - return !_.isEmpty(value.trim()); +function isRequiredFulfilled(value: string | Date | unknown[] | Record): boolean { + if (typeof value === 'string') { + return value.trim().length > 0; } - if (_.isDate(value)) { + + if (isDate(value)) { return isValidDate(value); } - if (_.isArray(value) || _.isObject(value)) { - return !_.isEmpty(value); + if (Array.isArray(value) || isObject(value)) { + return !isEmpty(value); } return Boolean(value); } /** * Used to add requiredField error to the fields passed. - * - * @param {Object} values - * @param {Array} requiredFields - * @returns {Object} */ -function getFieldRequiredErrors(values, requiredFields) { - const errors = {}; - _.each(requiredFields, (fieldKey) => { +function getFieldRequiredErrors(values: OnyxCommon.Errors, requiredFields: string[]) { + const errors: OnyxCommon.Errors = {}; + requiredFields.forEach((fieldKey) => { if (isRequiredFulfilled(values[fieldKey])) { return; } @@ -119,11 +105,8 @@ function getFieldRequiredErrors(values, requiredFields) { * 2. MM/YYYY * 3. MMYY * 4. MMYYYY - * - * @param {String} string - * @returns {Boolean} */ -function isValidExpirationDate(string) { +function isValidExpirationDate(string: string): boolean { if (!CONST.REGEX.CARD_EXPIRATION_DATE.test(string)) { return false; } @@ -136,21 +119,15 @@ function isValidExpirationDate(string) { /** * Validates that this is a valid security code * in the XXX or XXXX format. - * - * @param {String} string - * @returns {Boolean} */ -function isValidSecurityCode(string) { +function isValidSecurityCode(string: string): boolean { return CONST.REGEX.CARD_SECURITY_CODE.test(string); } /** * Validates a debit card number (15 or 16 digits). - * - * @param {String} string - * @returns {Boolean} */ -function isValidDebitCard(string) { +function isValidDebitCard(string: string): boolean { if (!CONST.REGEX.CARD_NUMBER.test(string)) { return false; } @@ -158,45 +135,26 @@ function isValidDebitCard(string) { return validateCardNumber(string); } -/** - * @param {String} code - * @returns {Boolean} - */ -function isValidIndustryCode(code) { +function isValidIndustryCode(code: string): boolean { return CONST.REGEX.INDUSTRY_CODE.test(code); } -/** - * @param {String} zipCode - * @returns {Boolean} - */ -function isValidZipCode(zipCode) { +function isValidZipCode(zipCode: string): boolean { return CONST.REGEX.ZIP_CODE.test(zipCode); } -/** - * @param {String} ssnLast4 - * @returns {Boolean} - */ -function isValidSSNLastFour(ssnLast4) { +function isValidSSNLastFour(ssnLast4: string): boolean { return CONST.REGEX.SSN_LAST_FOUR.test(ssnLast4); } -/** - * @param {String} ssnFull9 - * @returns {Boolean} - */ -function isValidSSNFullNine(ssnFull9) { +function isValidSSNFullNine(ssnFull9: string): boolean { return CONST.REGEX.SSN_FULL_NINE.test(ssnFull9); } /** * Validate that a date meets the minimum age requirement. - * - * @param {String} date - * @returns {Boolean} */ -function meetsMinimumAgeRequirement(date) { +function meetsMinimumAgeRequirement(date: string): boolean { const testDate = new Date(date); const minDate = subYears(new Date(), CONST.DATE_BIRTH.MIN_AGE_FOR_PAYMENT); return isValid(testDate) && (isSameDay(testDate, minDate) || isBefore(testDate, minDate)); @@ -204,11 +162,8 @@ function meetsMinimumAgeRequirement(date) { /** * Validate that a date meets the maximum age requirement. - * - * @param {String} date - * @returns {Boolean} */ -function meetsMaximumAgeRequirement(date) { +function meetsMaximumAgeRequirement(date: string): boolean { const testDate = new Date(date); const maxDate = subYears(new Date(), CONST.DATE_BIRTH.MAX_AGE); return isValid(testDate) && (isSameDay(testDate, maxDate) || isAfter(testDate, maxDate)); @@ -216,13 +171,8 @@ function meetsMaximumAgeRequirement(date) { /** * Validate that given date is in a specified range of years before now. - * - * @param {String} date - * @param {Number} minimumAge - * @param {Number} maximumAge - * @returns {String|Array} */ -function getAgeRequirementError(date, minimumAge, maximumAge) { +function getAgeRequirementError(date: string, minimumAge: number, maximumAge: number): string | Array> { const currentDate = startOfDay(new Date()); const testDate = parse(date, CONST.DATE.FNS_FORMAT_STRING, currentDate); @@ -247,24 +197,17 @@ function getAgeRequirementError(date, minimumAge, maximumAge) { /** * Similar to backend, checks whether a website has a valid URL or not. * http/https/ftp URL scheme required. - * - * @param {String} url - * @returns {Boolean} */ -function isValidWebsite(url) { +function isValidWebsite(url: string): boolean { return new RegExp(`^${URL_REGEX_WITH_REQUIRED_PROTOCOL}$`, 'i').test(url); } -/** - * @param {Object} identity - * @returns {Object} - */ -function validateIdentity(identity) { +function validateIdentity(identity: Record): Record { const requiredFields = ['firstName', 'lastName', 'street', 'city', 'zipCode', 'state', 'ssnLast4', 'dob']; - const errors = {}; + const errors: Record = {}; // Check that all required fields are filled - _.each(requiredFields, (fieldName) => { + requiredFields.forEach((fieldName) => { if (isRequiredFulfilled(identity[fieldName])) { return; } @@ -293,58 +236,41 @@ function validateIdentity(identity) { return errors; } -/** - * @param {String} phoneNumber - * @param {Boolean} [isCountryCodeOptional] - * @returns {Boolean} - */ -function isValidUSPhone(phoneNumber = '', isCountryCodeOptional) { +function isValidUSPhone(phoneNumber = '', isCountryCodeOptional?: boolean): boolean { const phone = phoneNumber || ''; - const regionCode = isCountryCodeOptional ? CONST.COUNTRY.US : null; + const regionCode = isCountryCodeOptional ? CONST.COUNTRY.US : undefined; const parsedPhoneNumber = parsePhoneNumber(phone, {regionCode}); return parsedPhoneNumber.possible && parsedPhoneNumber.regionCode === CONST.COUNTRY.US; } -/** - * @param {string} validateCode - * @returns {Boolean} - */ -function isValidValidateCode(validateCode) { - return validateCode.match(CONST.VALIDATE_CODE_REGEX_STRING); +function isValidValidateCode(validateCode: string): boolean { + return Boolean(validateCode.match(CONST.VALIDATE_CODE_REGEX_STRING)); } -function isValidRecoveryCode(recoveryCode) { - return recoveryCode.match(CONST.RECOVERY_CODE_REGEX_STRING); +function isValidRecoveryCode(recoveryCode: string): boolean { + return Boolean(recoveryCode.match(CONST.RECOVERY_CODE_REGEX_STRING)); } -/** - * @param {String} code - * @returns {Boolean} - */ -function isValidTwoFactorCode(code) { +function isValidTwoFactorCode(code: string): boolean { return Boolean(code.match(CONST.REGEX.CODE_2FA)); } /** * Checks whether a value is a numeric string including `(`, `)`, `-` and optional leading `+` - * @param {String} input - * @returns {Boolean} */ -function isNumericWithSpecialChars(input) { +function isNumericWithSpecialChars(input: string): boolean { return /^\+?[\d\\+]*$/.test(LoginUtils.getPhoneNumberWithoutSpecialChars(input)); } /** * Checks the given number is a valid US Routing Number * using ABA routingNumber checksum algorithm: http://www.brainjar.com/js/validation/ - * @param {String} number - * @returns {Boolean} */ -function isValidRoutingNumber(number) { +function isValidRoutingNumber(routingNumber: string): boolean { let n = 0; - for (let i = 0; i < number.length; i += 3) { - n += parseInt(number.charAt(i), 10) * 3 + parseInt(number.charAt(i + 1), 10) * 7 + parseInt(number.charAt(i + 2), 10); + for (let i = 0; i < routingNumber.length; i += 3) { + n += parseInt(routingNumber.charAt(i), 10) * 3 + parseInt(routingNumber.charAt(i + 1), 10) * 7 + parseInt(routingNumber.charAt(i + 2), 10); } // If the resulting sum is an even multiple of ten (but not zero), @@ -357,57 +283,39 @@ function isValidRoutingNumber(number) { /** * Checks that the provided name doesn't contain any commas or semicolons - * - * @param {String} name - * @returns {Boolean} */ -function isValidDisplayName(name) { +function isValidDisplayName(name: string): boolean { return !name.includes(',') && !name.includes(';'); } /** * Checks that the provided legal name doesn't contain special characters - * - * @param {String} name - * @returns {Boolean} */ -function isValidLegalName(name) { +function isValidLegalName(name: string): boolean { return CONST.REGEX.ALPHABETIC_AND_LATIN_CHARS.test(name); } /** * Checks if the provided string includes any of the provided reserved words - * - * @param {String} value - * @param {String[]} reservedWords - * @returns {Boolean} */ -function doesContainReservedWord(value, reservedWords) { +function doesContainReservedWord(value: string, reservedWords: string[]): boolean { const valueToCheck = value.trim().toLowerCase(); - return _.some(reservedWords, (reservedWord) => valueToCheck.includes(reservedWord.toLowerCase())); + return reservedWords.some((reservedWord) => valueToCheck.includes(reservedWord.toLowerCase())); } /** * Checks if is one of the certain names which are reserved for default rooms * and should not be used for policy rooms. - * - * @param {String} roomName - * @returns {Boolean} */ -function isReservedRoomName(roomName) { - return _.contains(CONST.REPORT.RESERVED_ROOM_NAMES, roomName); +function isReservedRoomName(roomName: string): boolean { + return (CONST.REPORT.RESERVED_ROOM_NAMES as readonly string[]).includes(roomName); } /** * Checks if the room name already exists. - * - * @param {String} roomName - * @param {Object} reports - * @param {String} policyID - * @returns {Boolean} */ -function isExistingRoomName(roomName, reports, policyID) { - return _.some(reports, (report) => report && report.policyID === policyID && report.reportName === roomName); +function isExistingRoomName(roomName: string, reports: Report[], policyID: string): boolean { + return reports.some((report) => report && report.policyID === policyID && report.reportName === roomName); } /** @@ -415,31 +323,22 @@ function isExistingRoomName(roomName, reports, policyID) { * - It starts with a hash '#' * - After the first character, it contains only lowercase letters, numbers, and dashes * - It's between 1 and MAX_ROOM_NAME_LENGTH characters long - * - * @param {String} roomName - * @returns {Boolean} */ -function isValidRoomName(roomName) { +function isValidRoomName(roomName: string): boolean { return CONST.REGEX.ROOM_NAME.test(roomName); } /** * Checks if tax ID consists of 9 digits - * - * @param {String} taxID - * @returns {Boolean} */ -function isValidTaxID(taxID) { - return taxID && CONST.REGEX.TAX_ID.test(taxID); +function isValidTaxID(taxID: string): boolean { + return CONST.REGEX.TAX_ID.test(taxID); } /** * Checks if a string value is a number. - * - * @param {String} value - * @returns {Boolean} */ -function isNumeric(value) { +function isNumeric(value: string): boolean { if (typeof value !== 'string') { return false; } @@ -448,12 +347,9 @@ function isNumeric(value) { /** * Checks that the provided accountID is a number and bigger than 0. - * - * @param {Number} accountID - * @returns {Boolean} */ -function isValidAccountRoute(accountID) { - return CONST.REGEX.NUMBER.test(accountID) && accountID > 0; +function isValidAccountRoute(accountID: number): boolean { + return CONST.REGEX.NUMBER.test(String(accountID)) && accountID > 0; } export { diff --git a/src/libs/actions/Chronos.js b/src/libs/actions/Chronos.ts similarity index 82% rename from src/libs/actions/Chronos.js rename to src/libs/actions/Chronos.ts index b9c0eed7b354..1b46a68a1afe 100644 --- a/src/libs/actions/Chronos.js +++ b/src/libs/actions/Chronos.ts @@ -1,16 +1,10 @@ -import _ from 'underscore'; import Onyx from 'react-native-onyx'; import CONST from '../../CONST'; import ONYXKEYS from '../../ONYXKEYS'; import * as API from '../API'; +import {ChronosOOOEvent} from '../../types/onyx/OriginalMessage'; -/** - * @param {String} reportID - * @param {String} reportActionID - * @param {String} eventID - * @param {Object[]} events - */ -const removeEvent = (reportID, reportActionID, eventID, events) => { +const removeEvent = (reportID: string, reportActionID: string, eventID: string, events: ChronosOOOEvent[]) => { const optimisticData = [ { onyxMethod: Onyx.METHOD.MERGE, @@ -19,7 +13,7 @@ const removeEvent = (reportID, reportActionID, eventID, events) => { [reportActionID]: { pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, originalMessage: { - events: _.reject(events, (event) => event.id === eventID), + events: events.filter((event) => event.id !== eventID), }, }, }, diff --git a/src/libs/actions/CloseAccount.js b/src/libs/actions/CloseAccount.ts similarity index 100% rename from src/libs/actions/CloseAccount.js rename to src/libs/actions/CloseAccount.ts diff --git a/src/libs/actions/Device/getDeviceInfo/getOSAndName/index.js b/src/libs/actions/Device/getDeviceInfo/getOSAndName/index.js deleted file mode 100644 index 29b004412f64..000000000000 --- a/src/libs/actions/Device/getDeviceInfo/getOSAndName/index.js +++ /dev/null @@ -1,4 +0,0 @@ -// Don't import this file with '* as Device'. It's known to make VSCode IntelliSense crash. -import {getOSAndName} from 'expensify-common/lib/Device'; - -export default getOSAndName; diff --git a/src/libs/actions/Device/getDeviceInfo/getOSAndName/index.native.js b/src/libs/actions/Device/getDeviceInfo/getOSAndName/index.native.ts similarity index 51% rename from src/libs/actions/Device/getDeviceInfo/getOSAndName/index.native.js rename to src/libs/actions/Device/getDeviceInfo/getOSAndName/index.native.ts index 11d59abea1f1..bb9eb572570e 100644 --- a/src/libs/actions/Device/getDeviceInfo/getOSAndName/index.native.js +++ b/src/libs/actions/Device/getDeviceInfo/getOSAndName/index.native.ts @@ -1,11 +1,18 @@ import Str from 'expensify-common/lib/str'; import RNDeviceInfo from 'react-native-device-info'; +import GetOSAndName from './types'; -export default function getOSAndName() { +const getOSAndName: GetOSAndName = () => { const deviceName = RNDeviceInfo.getDeviceNameSync(); const prettyName = `${Str.UCFirst(RNDeviceInfo.getManufacturerSync() || '')} ${deviceName}`; return { + // Parameter names are predefined and we don't choose it here + // eslint-disable-next-line @typescript-eslint/naming-convention device_name: RNDeviceInfo.isEmulatorSync() ? `Emulator - ${prettyName}` : prettyName, + // Parameter names are predefined and we don't choose it here + // eslint-disable-next-line @typescript-eslint/naming-convention os_version: RNDeviceInfo.getSystemVersion(), }; -} +}; + +export default getOSAndName; diff --git a/src/libs/actions/Device/getDeviceInfo/getOSAndName/index.ts b/src/libs/actions/Device/getDeviceInfo/getOSAndName/index.ts new file mode 100644 index 000000000000..d63c2fedc51d --- /dev/null +++ b/src/libs/actions/Device/getDeviceInfo/getOSAndName/index.ts @@ -0,0 +1,12 @@ +import {getOSAndName as libGetOSAndName} from 'expensify-common/lib/Device'; +import GetOSAndName from './types'; + +const getOSAndName: GetOSAndName = () => { + // Parameter names are predefined and we don't choose it here + // eslint-disable-next-line @typescript-eslint/naming-convention + const {device_name, os_version} = libGetOSAndName(); + // Parameter names are predefined and we don't choose it here + // eslint-disable-next-line @typescript-eslint/naming-convention + return {device_name, os_version}; +}; +export default getOSAndName; diff --git a/src/libs/actions/Device/getDeviceInfo/getOSAndName/types.ts b/src/libs/actions/Device/getDeviceInfo/getOSAndName/types.ts new file mode 100644 index 000000000000..2ca67c3c59c3 --- /dev/null +++ b/src/libs/actions/Device/getDeviceInfo/getOSAndName/types.ts @@ -0,0 +1,4 @@ +// Parameter names are predefined and we don't choose it here +// eslint-disable-next-line @typescript-eslint/naming-convention +type GetOSAndName = () => {device_name: string | undefined; os_version: string | undefined}; +export default GetOSAndName; diff --git a/src/libs/actions/EmojiPickerAction.js b/src/libs/actions/EmojiPickerAction.js deleted file mode 100644 index 70c7ebabbe20..000000000000 --- a/src/libs/actions/EmojiPickerAction.js +++ /dev/null @@ -1,69 +0,0 @@ -import React from 'react'; - -const emojiPickerRef = React.createRef(); - -/** - * Show the EmojiPicker modal popover. - * - * @param {Function} [onModalHide=() => {}] - Run a callback when Modal hides. - * @param {Function} [onEmojiSelected=() => {}] - Run a callback when Emoji selected. - * @param {Element} emojiPopoverAnchor - Element on which EmojiPicker is anchored - * @param {Object} [anchorOrigin] - Anchor origin for Popover - * @param {Function} [onWillShow=() => {}] - Run a callback when Popover will show - * @param {String} id - Unique id for EmojiPicker - */ -function showEmojiPicker(onModalHide = () => {}, onEmojiSelected = () => {}, emojiPopoverAnchor, anchorOrigin = undefined, onWillShow = () => {}, id) { - if (!emojiPickerRef.current) { - return; - } - - emojiPickerRef.current.showEmojiPicker(onModalHide, onEmojiSelected, emojiPopoverAnchor, anchorOrigin, onWillShow, id); -} - -/** - * Hide the Emoji Picker modal. - * - * @param {Boolean} isNavigating - */ -function hideEmojiPicker(isNavigating) { - if (!emojiPickerRef.current) { - return; - } - emojiPickerRef.current.hideEmojiPicker(isNavigating); -} - -/** - * Whether Emoji Picker is active for the given id. - * - * @param {String} id - * @return {Boolean} - */ -function isActive(id) { - if (!emojiPickerRef.current) { - return; - } - return emojiPickerRef.current.isActive(id); -} - -function clearActive() { - if (!emojiPickerRef.current) { - return; - } - return emojiPickerRef.current.clearActive(); -} - -function isEmojiPickerVisible() { - if (!emojiPickerRef.current) { - return; - } - return emojiPickerRef.current.isEmojiPickerVisible; -} - -function resetEmojiPopoverAnchor() { - if (!emojiPickerRef.current) { - return; - } - return emojiPickerRef.current.resetEmojiPopoverAnchor(); -} - -export {emojiPickerRef, showEmojiPicker, hideEmojiPicker, isActive, clearActive, isEmojiPickerVisible, resetEmojiPopoverAnchor}; diff --git a/src/libs/actions/EmojiPickerAction.ts b/src/libs/actions/EmojiPickerAction.ts new file mode 100644 index 000000000000..edf82eb46da3 --- /dev/null +++ b/src/libs/actions/EmojiPickerAction.ts @@ -0,0 +1,87 @@ +import {ValueOf} from 'type-fest'; +import React from 'react'; +import {View} from 'react-native'; +import CONST from '../../CONST'; + +type AnchorOrigin = { + horizontal: ValueOf; + vertical: ValueOf; +}; + +// TODO: Move this type to src/components/EmojiPicker/EmojiPicker.js once it is converted to TS +type EmojiPickerRef = { + showEmojiPicker: (onModalHideValue?: () => void, onEmojiSelectedValue?: () => void, emojiPopoverAnchor?: View, anchorOrigin?: AnchorOrigin, onWillShow?: () => void, id?: string) => void; + isActive: (id: string) => boolean; + clearActive: () => void; + hideEmojiPicker: (isNavigating: boolean) => void; + isEmojiPickerVisible: boolean; + resetEmojiPopoverAnchor: () => void; +}; + +const emojiPickerRef = React.createRef(); + +/** + * Show the EmojiPicker modal popover. + * + * @param onModalHide - Run a callback when Modal hides. + * @param onEmojiSelected - Run a callback when Emoji selected. + * @param emojiPopoverAnchor - Element on which EmojiPicker is anchored + * @param anchorOrigin - Anchor origin for Popover + * @param onWillShow - Run a callback when Popover will show + * @param id - Unique id for EmojiPicker + */ +function showEmojiPicker(onModalHide = () => {}, onEmojiSelected = () => {}, emojiPopoverAnchor = undefined, anchorOrigin = undefined, onWillShow = () => {}, id = undefined) { + if (!emojiPickerRef.current) { + return; + } + + emojiPickerRef.current.showEmojiPicker(onModalHide, onEmojiSelected, emojiPopoverAnchor, anchorOrigin, onWillShow, id); +} + +/** + * Hide the Emoji Picker modal. + */ +function hideEmojiPicker(isNavigating: boolean) { + if (!emojiPickerRef.current) { + return; + } + + emojiPickerRef.current.hideEmojiPicker(isNavigating); +} + +/** + * Whether Emoji Picker is active for the given id. + */ +function isActive(id: string): boolean { + if (!emojiPickerRef.current) { + return false; + } + + return emojiPickerRef.current.isActive(id); +} + +function clearActive() { + if (!emojiPickerRef.current) { + return; + } + + return emojiPickerRef.current.clearActive(); +} + +function isEmojiPickerVisible(): boolean { + if (!emojiPickerRef.current) { + return false; + } + + return emojiPickerRef.current.isEmojiPickerVisible; +} + +function resetEmojiPopoverAnchor() { + if (!emojiPickerRef.current) { + return; + } + + emojiPickerRef.current.resetEmojiPopoverAnchor(); +} + +export {emojiPickerRef, showEmojiPicker, hideEmojiPicker, isActive, clearActive, isEmojiPickerVisible, resetEmojiPopoverAnchor}; diff --git a/src/libs/actions/IOU.js b/src/libs/actions/IOU.js index 085842af18b9..198ceb2b8172 100644 --- a/src/libs/actions/IOU.js +++ b/src/libs/actions/IOU.js @@ -75,11 +75,29 @@ Onyx.connect({ }, }); +let didInitCurrency = false; +Onyx.connect({ + key: ONYXKEYS.IOU, + callback: (val) => { + didInitCurrency = lodashGet(val, 'didInitCurrency'); + }, +}); + +let shouldResetIOUAfterLogin = true; let currentUserPersonalDetails = {}; Onyx.connect({ key: ONYXKEYS.PERSONAL_DETAILS_LIST, callback: (val) => { currentUserPersonalDetails = lodashGet(val, userAccountID, {}); + if (!val || !shouldResetIOUAfterLogin || didInitCurrency) { + return; + } + // eslint-disable-next-line no-use-before-define + resetMoneyRequestInfo(); + shouldResetIOUAfterLogin = false; + Onyx.merge(ONYXKEYS.IOU, { + didInitCurrency: true, + }); }, }); @@ -102,9 +120,7 @@ function resetMoneyRequestInfo(id = '') { amount: 0, currency: lodashGet(currentUserPersonalDetails, 'localCurrencyCode', CONST.CURRENCY.USD), comment: '', - // TODO: remove participants after all instances of iou.participants will be replaced with iou.participantAccountIDs participants: [], - participantAccountIDs: [], merchant: CONST.TRANSACTION.DEFAULT_MERCHANT, category: '', tag: '', @@ -588,8 +604,9 @@ function getMoneyRequestInformation( * @param {Number} amount * @param {String} currency * @param {String} merchant + * @param {Boolean} [billable] */ -function createDistanceRequest(report, participant, comment, created, transactionID, category, tag, amount, currency, merchant) { +function createDistanceRequest(report, participant, comment, created, transactionID, category, tag, amount, currency, merchant, billable) { const optimisticReceipt = { source: ReceiptGeneric, state: CONST.IOU.RECEIPT_STATE.OPEN, @@ -608,6 +625,7 @@ function createDistanceRequest(report, participant, comment, created, transactio transactionID, category, tag, + billable, ); API.write( 'CreateDistanceRequest', @@ -624,6 +642,7 @@ function createDistanceRequest(report, participant, comment, created, transactio created, category, tag, + billable, }, onyxData, ); @@ -631,6 +650,154 @@ function createDistanceRequest(report, participant, comment, created, transactio Report.notifyNewAction(chatReport.reportID, userAccountID); } +/** + * Edits an existing distance request + * + * @param {String} transactionID + * @param {Number} transactionThreadReportID + * @param {Object} transactionChanges + * @param {String} [transactionChanges.created] + * @param {Number} [transactionChanges.amount] + * @param {Object} [transactionChanges.comment] + * @param {Object} [transactionChanges.waypoints] + * + */ +function updateDistanceRequest(transactionID, transactionThreadReportID, transactionChanges) { + const optimisticData = []; + const successData = []; + const failureData = []; + + // Step 1: Set any "pending fields" (ones updated while the user was offline) to have error messages in the failureData + const pendingFields = _.mapObject(transactionChanges, () => CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE); + const clearedPendingFields = _.mapObject(transactionChanges, () => null); + const errorFields = _.mapObject(pendingFields, () => ({ + [DateUtils.getMicroseconds()]: Localize.translateLocal('iou.error.genericEditFailureMessage'), + })); + + // Step 2: Get all the collections being updated + const transactionThread = allReports[`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`]; + const transaction = allTransactions[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]; + const iouReport = allReports[`${ONYXKEYS.COLLECTION.REPORT}${transactionThread.parentReportID}`]; + const isFromExpenseReport = ReportUtils.isExpenseReport(iouReport); + const updatedTransaction = TransactionUtils.getUpdatedTransaction(transaction, transactionChanges, isFromExpenseReport); + const transactionDetails = ReportUtils.getTransactionDetails(updatedTransaction); + + const params = { + ...transactionDetails, + transactionID, + // This needs to be a JSON string since we're sending this to the MapBox API + waypoints: JSON.stringify(transactionDetails.waypoints), + }; + + // Step 3: Build the modified expense report actions + // We don't create a modified report action if we're updating the waypoints, + // since there isn't actually any optimistic data we can create for them and the report action is created on the server + // with the response from the MapBox API + if (!_.has(transactionChanges, 'waypoints')) { + const updatedReportAction = ReportUtils.buildOptimisticModifiedExpenseReportAction(transactionThread, transaction, transactionChanges, isFromExpenseReport); + params.reportActionID = updatedReportAction.reportActionID; + + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThread.reportID}`, + value: { + [updatedReportAction.reportActionID]: updatedReportAction, + }, + }); + successData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThread.reportID}`, + value: { + [updatedReportAction.reportActionID]: {pendingAction: null}, + }, + }); + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThread.reportID}`, + value: { + [updatedReportAction.reportActionID]: updatedReportAction, + }, + }); + + // Step 4: Compute the IOU total and update the report preview message (and report header) so LHN amount owed is correct. + // Should only update if the transaction matches the currency of the report, else we wait for the update + // from the server with the currency conversion + let updatedMoneyRequestReport = {...iouReport}; + if (updatedTransaction.currency === iouReport.currency && updatedTransaction.modifiedAmount) { + const diff = TransactionUtils.getAmount(transaction, true) - TransactionUtils.getAmount(updatedTransaction, true); + if (ReportUtils.isExpenseReport(iouReport)) { + updatedMoneyRequestReport.total += diff; + } else { + updatedMoneyRequestReport = IOUUtils.updateIOUOwnerAndTotal(iouReport, updatedReportAction.actorAccountID, diff, TransactionUtils.getCurrency(transaction), false); + } + + updatedMoneyRequestReport.cachedTotal = CurrencyUtils.convertToDisplayString(updatedMoneyRequestReport.total, updatedTransaction.currency); + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport.reportID}`, + value: updatedMoneyRequestReport, + }); + successData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport.reportID}`, + value: {pendingAction: null}, + }); + } + } + + // Optimistically modify the transaction + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, + value: { + ...updatedTransaction, + pendingFields, + isLoading: _.has(transactionChanges, 'waypoints'), + errorFields: null, + }, + }); + + // Clear out the error fields and loading states on success + successData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, + value: { + pendingFields: clearedPendingFields, + isLoading: false, + errorFields: null, + }, + }); + + if (_.has(transactionChanges, 'waypoints')) { + // Delete the backup transaction when editing waypoints when the server responds successfully and there are no errors + successData.push({ + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}-backup`, + value: null, + }); + } + + // Clear out loading states, pending fields, and add the error fields + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, + value: { + pendingFields: clearedPendingFields, + isLoading: false, + errorFields, + }, + }); + + // Reset the iouReport to it's original state + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport.reportID}`, + value: iouReport, + }); + + API.write('UpdateDistanceRequest', params, {optimisticData, successData, failureData}); +} + /** * Request money from another user * @@ -1136,6 +1303,17 @@ function editMoneyRequest(transactionID, transactionThreadReportID, transactionC updatedChatReport.lastMessageHtml = messageText; } + const optimisticPolicyRecentlyUsedTags = {}; + if (_.has(transactionChanges, 'tag')) { + const tagListName = transactionChanges.tagListName; + const recentlyUsedPolicyTags = allRecentlyUsedTags[`${ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_TAGS}${iouReport.policyID}`]; + + if (recentlyUsedPolicyTags) { + const uniquePolicyRecentlyUsedTags = _.filter(recentlyUsedPolicyTags[tagListName], (recentlyUsedPolicyTag) => recentlyUsedPolicyTag !== transactionChanges.tag); + optimisticPolicyRecentlyUsedTags[tagListName] = [transactionChanges.tag, ...uniquePolicyRecentlyUsedTags]; + } + } + // STEP 4: Compose the optimistic data const currentTime = DateUtils.getDBTime(); const optimisticData = [ @@ -1171,6 +1349,14 @@ function editMoneyRequest(transactionID, transactionThreadReportID, transactionC }, ]; + if (!_.isEmpty(optimisticPolicyRecentlyUsedTags)) { + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_TAGS}${iouReport.policyID}`, + value: optimisticPolicyRecentlyUsedTags, + }); + } + const successData = [ { onyxMethod: Onyx.METHOD.MERGE, @@ -1189,7 +1375,9 @@ function editMoneyRequest(transactionID, transactionThreadReportID, transactionC created: null, currency: null, merchant: null, + billable: null, category: null, + tag: null, }, }, }, @@ -1236,7 +1424,7 @@ function editMoneyRequest(transactionID, transactionThreadReportID, transactionC ]; // STEP 6: Call the API endpoint - const {created, amount, currency, comment, merchant, category} = ReportUtils.getTransactionDetails(updatedTransaction); + const {created, amount, currency, comment, merchant, category, billable, tag} = ReportUtils.getTransactionDetails(updatedTransaction); API.write( 'EditMoneyRequest', { @@ -1248,6 +1436,8 @@ function editMoneyRequest(transactionID, transactionThreadReportID, transactionC comment, merchant, category, + billable, + tag, }, {optimisticData, successData, failureData}, ); @@ -2049,9 +2239,7 @@ function setMoneyRequestBillable(billable) { * @param {Object[]} participants */ function setMoneyRequestParticipants(participants) { - // TODO: temporarily we want to save both participants and participantAccountIDs, then we can remove participants (and rename the function) - // more info: https://github.com/Expensify/App/issues/25714#issuecomment-1712924903 and https://github.com/Expensify/App/issues/25714#issuecomment-1716335802 - Onyx.merge(ONYXKEYS.IOU, {participants, participantAccountIDs: _.map(participants, 'accountID')}); + Onyx.merge(ONYXKEYS.IOU, {participants}); } /** @@ -2073,12 +2261,12 @@ function createEmptyTransaction() { * * @param {Object} iou * @param {String} iouType - * @param {String} reportID * @param {Object} report + * @param {String} report.reportID * @param {String} path */ -function navigateToNextPage(iou, iouType, reportID, report, path = '') { - const moneyRequestID = `${iouType}${reportID}`; +function navigateToNextPage(iou, iouType, report, path = '') { + const moneyRequestID = `${iouType}${report.reportID || ''}`; const shouldReset = iou.id !== moneyRequestID; // If the money request ID in Onyx does not match the ID from params, we want to start a new request @@ -2088,8 +2276,8 @@ function navigateToNextPage(iou, iouType, reportID, report, path = '') { } // If we're adding a receipt, that means the user came from the confirmation page and we need to navigate back to it. - if (path.slice(1) === ROUTES.MONEY_REQUEST_RECEIPT.getRoute(iouType, reportID)) { - Navigation.navigate(ROUTES.MONEY_REQUEST_CONFIRMATION.getRoute(iouType, reportID)); + if (path.slice(1) === ROUTES.MONEY_REQUEST_RECEIPT.getRoute(iouType, report.reportID)) { + Navigation.navigate(ROUTES.MONEY_REQUEST_CONFIRMATION.getRoute(iouType, report.reportID)); return; } @@ -2108,7 +2296,7 @@ function navigateToNextPage(iou, iouType, reportID, report, path = '') { .value(); setMoneyRequestParticipants(participants); } - Navigation.navigate(ROUTES.MONEY_REQUEST_CONFIRMATION.getRoute(iouType, reportID)); + Navigation.navigate(ROUTES.MONEY_REQUEST_CONFIRMATION.getRoute(iouType, report.reportID)); return; } Navigation.navigate(ROUTES.MONEY_REQUEST_PARTICIPANTS.getRoute(iouType)); @@ -2142,5 +2330,6 @@ export { setMoneyRequestReceipt, createEmptyTransaction, navigateToNextPage, + updateDistanceRequest, replaceReceipt, }; diff --git a/src/libs/actions/MapboxToken.js b/src/libs/actions/MapboxToken.js index 2ce56eb1a11e..e5824ef0302d 100644 --- a/src/libs/actions/MapboxToken.js +++ b/src/libs/actions/MapboxToken.js @@ -46,11 +46,15 @@ const hasTokenExpired = () => moment().isAfter(currentToken.expiration); const clearToken = () => { console.debug('[MapboxToken] Deleting the token stored in Onyx'); - // Use Onyx.set() to delete the key from Onyx, which will trigger a new token to be retrieved from the API. Onyx.set(ONYXKEYS.MAPBOX_ACCESS_TOKEN, null); }; +const fetchToken = () => { + API.read('GetMapboxAccessToken'); + isCurrentlyFetchingToken = true; +}; + const init = () => { if (connectionIDForToken) { console.debug('[MapboxToken] init() is already listening to Onyx so returning early'); @@ -83,9 +87,7 @@ const init = () => { // If the token is falsy or an empty object, the token needs to be retrieved from the API. // The API sets a token in Onyx with a 30 minute expiration. if (_.isEmpty(token)) { - console.debug('[MapboxToken] Token does not exist so fetching one'); - API.read('GetMapboxAccessToken'); - isCurrentlyFetchingToken = true; + fetchToken(); return; } @@ -126,9 +128,13 @@ const init = () => { callback: (val) => { // When the network reconnects, check if the token has expired. If it has, then clearing the token will // trigger the fetch of a new one - if (network && network.isOffline && val && !val.isOffline && !isCurrentlyFetchingToken && hasTokenExpired()) { - console.debug('[MapboxToken] Token is expired after network came online'); - clearToken(); + if (network && network.isOffline && val && !val.isOffline) { + if (_.isEmpty(currentToken)) { + fetchToken(); + } else if (!isCurrentlyFetchingToken && hasTokenExpired()) { + console.debug('[MapboxToken] Token is expired after network came online'); + clearToken(); + } } network = val; }, diff --git a/src/libs/actions/OnyxUpdateManager.js b/src/libs/actions/OnyxUpdateManager.js index e0f3f8fd4622..b6318b784439 100644 --- a/src/libs/actions/OnyxUpdateManager.js +++ b/src/libs/actions/OnyxUpdateManager.js @@ -88,6 +88,7 @@ export default () => { canUnpauseQueuePromise.finally(() => { OnyxUpdates.apply(updateParams).finally(() => { console.debug('[OnyxUpdateManager] Done applying all updates'); + Onyx.set(ONYXKEYS.ONYX_UPDATES_FROM_SERVER, null); SequentialQueue.unpause(); }); }); diff --git a/src/libs/actions/PersonalDetails.js b/src/libs/actions/PersonalDetails.js index dd80992e64a4..69cf05b89b34 100644 --- a/src/libs/actions/PersonalDetails.js +++ b/src/libs/actions/PersonalDetails.js @@ -429,6 +429,7 @@ function updateAvatar(file) { avatar: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, originalFileName: null, }, + fallbackIcon: file.uri, }, }, }, @@ -479,6 +480,7 @@ function deleteAvatar() { value: { [currentUserAccountID]: { avatar: defaultAvatar, + fallbackIcon: null, }, }, }, @@ -490,6 +492,7 @@ function deleteAvatar() { value: { [currentUserAccountID]: { avatar: allPersonalDetails[currentUserAccountID].avatar, + fallbackIcon: allPersonalDetails[currentUserAccountID].fallbackIcon, }, }, }, diff --git a/src/libs/actions/Policy.js b/src/libs/actions/Policy.js index e42ef1ac4823..fcce909c5582 100644 --- a/src/libs/actions/Policy.js +++ b/src/libs/actions/Policy.js @@ -310,7 +310,7 @@ function createPolicyExpenseChats(policyID, invitedEmailsToAccountIDs) { workspaceMembersChats.onyxFailureData.push({ onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${optimisticReport.reportID}`, + key: `${ONYXKEYS.COLLECTION.REPORT_METADATA}${optimisticReport.reportID}`, value: { isLoadingReportActions: false, }, diff --git a/src/libs/actions/Report.js b/src/libs/actions/Report.js index c7d8195b8803..b92862c5b5e1 100644 --- a/src/libs/actions/Report.js +++ b/src/libs/actions/Report.js @@ -450,43 +450,63 @@ function reportActionsExist(reportID) { * @param {Array} participantAccountIDList The list of accountIDs that are included in a new chat, not including the user creating it */ function openReport(reportID, participantLoginList = [], newReportObject = {}, parentReportActionID = '0', isFromDeepLink = false, participantAccountIDList = []) { - const optimisticReportData = { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, - value: reportActionsExist(reportID) - ? {} - : { - isLoadingReportActions: true, - isLoadingMoreReportActions: false, - reportName: lodashGet(allReports, [reportID, 'reportName'], CONST.REPORT.DEFAULT_REPORT_NAME), - }, - }; - const reportSuccessData = { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, - value: { - isLoadingReportActions: false, - pendingFields: { - createChat: null, + const optimisticReportData = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, + value: reportActionsExist(reportID) + ? {} + : { + reportName: lodashGet(allReports, [reportID, 'reportName'], CONST.REPORT.DEFAULT_REPORT_NAME), + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_METADATA}${reportID}`, + value: { + isLoadingReportActions: true, + isLoadingMoreReportActions: false, }, - errorFields: { - createChat: null, + }, + ]; + + const reportSuccessData = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, + value: { + pendingFields: { + createChat: null, + }, + errorFields: { + createChat: null, + }, + isOptimisticReport: false, }, - isOptimisticReport: false, }, - }; - const reportFailureData = { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, - value: { - isLoadingReportActions: false, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_METADATA}${reportID}`, + value: { + isLoadingReportActions: false, + }, }, - }; + ]; + + const reportFailureData = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_METADATA}${reportID}`, + value: { + isLoadingReportActions: false, + }, + }, + ]; const onyxData = { - optimisticData: [optimisticReportData], - successData: [reportSuccessData], - failureData: [reportFailureData], + optimisticData: optimisticReportData, + successData: reportSuccessData, + failureData: reportFailureData, }; const params = { @@ -503,17 +523,17 @@ function openReport(reportID, participantLoginList = [], newReportObject = {}, p // If we open an exist report, but it is not present in Onyx yet, we should change the method to set for this report // and we need data to be available when we navigate to the chat page if (_.isEmpty(ReportUtils.getReport(reportID))) { - optimisticReportData.onyxMethod = Onyx.METHOD.SET; + onyxData.optimisticData[0].onyxMethod = Onyx.METHOD.SET; } // If we are creating a new report, we need to add the optimistic report data and a report action if (!_.isEmpty(newReportObject)) { // Change the method to set for new reports because it doesn't exist yet, is faster, // and we need the data to be available when we navigate to the chat page - optimisticReportData.onyxMethod = Onyx.METHOD.SET; - optimisticReportData.value = { + onyxData.optimisticData[0].onyxMethod = Onyx.METHOD.SET; + onyxData.optimisticData[0].value = { reportName: CONST.REPORT.DEFAULT_REPORT_NAME, - ...optimisticReportData.value, + ...onyxData.optimisticData[0].value, ...newReportObject, pendingFields: { createChat: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, @@ -696,17 +716,23 @@ function reconnect(reportID) { { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, + value: { + reportName: lodashGet(allReports, [reportID, 'reportName'], CONST.REPORT.DEFAULT_REPORT_NAME), + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_METADATA}${reportID}`, value: { isLoadingReportActions: true, isLoadingMoreReportActions: false, - reportName: lodashGet(allReports, [reportID, 'reportName'], CONST.REPORT.DEFAULT_REPORT_NAME), }, }, ], successData: [ { onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, + key: `${ONYXKEYS.COLLECTION.REPORT_METADATA}${reportID}`, value: { isLoadingReportActions: false, }, @@ -715,7 +741,7 @@ function reconnect(reportID) { failureData: [ { onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, + key: `${ONYXKEYS.COLLECTION.REPORT_METADATA}${reportID}`, value: { isLoadingReportActions: false, }, @@ -1249,11 +1275,12 @@ function editReportComment(reportID, originalReportAction, textForNewComment) { * Saves the draft for a comment report action. This will put the comment into "edit mode" * * @param {String} reportID - * @param {Number} reportActionID + * @param {Object} reportAction * @param {String} draftMessage */ -function saveReportActionDraft(reportID, reportActionID, draftMessage) { - Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${reportID}_${reportActionID}`, draftMessage); +function saveReportActionDraft(reportID, reportAction, draftMessage) { + const originalReportID = ReportUtils.getOriginalReportID(reportID, reportAction); + Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${originalReportID}_${reportAction.reportActionID}`, draftMessage); } /** @@ -1627,9 +1654,9 @@ function shouldShowReportActionNotification(reportID, action = null, isRemote = return false; } - // Don't show a notification if no comment exists - if (action && !_.some(action.message, (f) => f.type === 'COMMENT')) { - Log.info(`${tag} No notification because no comments exist for the current action`); + // Only show notifications for supported types of report actions + if (!ReportActionsUtils.isNotifiableReportAction(action)) { + Log.info(`${tag} No notification because this action type is not supported`, false, {actionName: action.actionName}); return false; } @@ -1638,23 +1665,28 @@ function shouldShowReportActionNotification(reportID, action = null, isRemote = /** * @param {String} reportID - * @param {Object} action + * @param {Object} reportAction */ -function showReportActionNotification(reportID, action) { - if (!shouldShowReportActionNotification(reportID, action)) { +function showReportActionNotification(reportID, reportAction) { + if (!shouldShowReportActionNotification(reportID, reportAction)) { return; } Log.info('[LocalNotification] Creating notification'); - LocalNotification.showCommentNotification({ - report: allReports[reportID], - reportAction: action, - onClick: () => { - // Navigate to this report onClick - Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(reportID)); - }, - }); - notifyNewAction(reportID, action.actorAccountID, action.reportActionID); + const report = allReports[reportID]; + + const notificationParams = { + report, + reportAction, + onClick: () => Navigation.navigate(ROUTES.getReportRoute(reportID)), + }; + if (reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.MODIFIEDEXPENSE) { + LocalNotification.showModifiedExpenseNotification(notificationParams); + } else { + LocalNotification.showCommentNotification(notificationParams); + } + + notifyNewAction(reportID, reportAction.actorAccountID, reportAction.reportActionID); } /** diff --git a/src/libs/actions/Session/index.js b/src/libs/actions/Session/index.js index 8a8aabf6e8f4..117a092c3875 100644 --- a/src/libs/actions/Session/index.js +++ b/src/libs/actions/Session/index.js @@ -45,6 +45,12 @@ Onyx.connect({ callback: (val) => (credentials = val || {}), }); +let preferredLocale; +Onyx.connect({ + key: ONYXKEYS.NVP_PREFERRED_LOCALE, + callback: (val) => (preferredLocale = val), +}); + /** * Clears the Onyx store and redirects user to the sign in page */ @@ -254,7 +260,7 @@ function beginSignIn(login) { */ function beginAppleSignIn(idToken) { const {optimisticData, successData, failureData} = signInAttemptState(); - API.write('SignInWithApple', {idToken}, {optimisticData, successData, failureData}); + API.write('SignInWithApple', {idToken, preferredLocale}, {optimisticData, successData, failureData}); } /** @@ -265,7 +271,7 @@ function beginAppleSignIn(idToken) { */ function beginGoogleSignIn(token) { const {optimisticData, successData, failureData} = signInAttemptState(); - API.write('SignInWithGoogle', {token}, {optimisticData, successData, failureData}); + API.write('SignInWithGoogle', {token, preferredLocale}, {optimisticData, successData, failureData}); } /** @@ -321,9 +327,8 @@ function signInWithShortLivedAuthToken(email, authToken) { * * @param {String} validateCode 6 digit code required for login * @param {String} [twoFactorAuthCode] - * @param {String} [preferredLocale] Indicates which language to use when the user lands in the app */ -function signIn(validateCode, twoFactorAuthCode, preferredLocale = CONST.LOCALES.DEFAULT) { +function signIn(validateCode, twoFactorAuthCode) { const optimisticData = [ { onyxMethod: Onyx.METHOD.MERGE, @@ -380,7 +385,7 @@ function signIn(validateCode, twoFactorAuthCode, preferredLocale = CONST.LOCALES }); } -function signInWithValidateCode(accountID, code, preferredLocale = CONST.LOCALES.DEFAULT, twoFactorAuthCode = '') { +function signInWithValidateCode(accountID, code, twoFactorAuthCode = '') { // If this is called from the 2fa step, get the validateCode directly from onyx // instead of the one passed from the component state because the state is changing when this method is called. const validateCode = twoFactorAuthCode ? credentials.validateCode : code; @@ -456,8 +461,8 @@ function signInWithValidateCode(accountID, code, preferredLocale = CONST.LOCALES }); } -function signInWithValidateCodeAndNavigate(accountID, validateCode, preferredLocale = CONST.LOCALES.DEFAULT, twoFactorAuthCode = '') { - signInWithValidateCode(accountID, validateCode, preferredLocale, twoFactorAuthCode); +function signInWithValidateCodeAndNavigate(accountID, validateCode, twoFactorAuthCode = '') { + signInWithValidateCode(accountID, validateCode, twoFactorAuthCode); Navigation.navigate(ROUTES.HOME); } diff --git a/src/libs/actions/Task.js b/src/libs/actions/Task.js index 963bfebb7eb2..91267b9b1053 100644 --- a/src/libs/actions/Task.js +++ b/src/libs/actions/Task.js @@ -70,7 +70,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, `Created a task: ${title}`, parentReportID); + const optimisticAddCommentReport = ReportUtils.buildOptimisticTaskCommentReportAction(taskReportID, title, assigneeEmail, assigneeAccountID, `task for ${title}`, parentReportID); optimisticTaskReport.parentReportActionID = optimisticAddCommentReport.reportAction.reportActionID; const currentTime = DateUtils.getDBTime(); @@ -219,11 +219,10 @@ function createTaskAndNavigate(parentReportID, title, description, assigneeEmail /** * Complete a task * @param {Object} taskReport task report - * @param {String} taskTitle Title of the task */ -function completeTask(taskReport, taskTitle) { +function completeTask(taskReport) { const taskReportID = taskReport.reportID; - const message = `completed task: ${taskTitle}`; + const message = `marked as complete`; const completedTaskReportAction = ReportUtils.buildOptimisticTaskReportAction(taskReportID, CONST.REPORT.ACTIONS.TYPE.TASKCOMPLETED, message); const optimisticData = [ @@ -303,11 +302,10 @@ function completeTask(taskReport, taskTitle) { /** * Reopen a closed task * @param {Object} taskReport task report - * @param {String} taskTitle Title of the task */ -function reopenTask(taskReport, taskTitle) { +function reopenTask(taskReport) { const taskReportID = taskReport.reportID; - const message = `reopened task: ${taskTitle}`; + const message = `marked as incomplete`; const reopenedTaskReportAction = ReportUtils.buildOptimisticTaskReportAction(taskReportID, CONST.REPORT.ACTIONS.TYPE.TASKREOPENED, message); const optimisticData = [ @@ -924,7 +922,7 @@ function clearEditTaskErrors(reportID) { function getTaskReportActionMessage(actionName, reportID, isCreateTaskAction) { const report = ReportUtils.getReport(reportID); if (isCreateTaskAction) { - return `Created a task: ${report.reportName}`; + return `task for ${report.reportName}`; } let taskStatusText = ''; switch (actionName) { @@ -941,7 +939,7 @@ function getTaskReportActionMessage(actionName, reportID, isCreateTaskAction) { taskStatusText = Localize.translateLocal('task.task'); } - return `${taskStatusText} ${report.reportName}`; + return `${taskStatusText}`; } export { diff --git a/src/libs/actions/Transaction.js b/src/libs/actions/Transaction.ts similarity index 75% rename from src/libs/actions/Transaction.js rename to src/libs/actions/Transaction.ts index 81764a9c62be..fe1bc1621cfa 100644 --- a/src/libs/actions/Transaction.js +++ b/src/libs/actions/Transaction.ts @@ -1,19 +1,21 @@ -import _ from 'underscore'; import Onyx from 'react-native-onyx'; -import lodashGet from 'lodash/get'; import lodashHas from 'lodash/has'; +import lodashClone from 'lodash/clone'; import ONYXKEYS from '../../ONYXKEYS'; import * as CollectionUtils from '../CollectionUtils'; import * as API from '../API'; +import CONST from '../../CONST'; +import {RecentWaypoint, Transaction} from '../../types/onyx'; +import {WaypointCollection} from '../../types/onyx/Transaction'; import * as TransactionUtils from '../TransactionUtils'; -let recentWaypoints = []; +let recentWaypoints: RecentWaypoint[] = []; Onyx.connect({ key: ONYXKEYS.NVP_RECENT_WAYPOINTS, - callback: (val) => (recentWaypoints = val || []), + callback: (val) => (recentWaypoints = val ?? []), }); -const allTransactions = {}; +const allTransactions: Record = {}; Onyx.connect({ key: ONYXKEYS.COLLECTION.TRANSACTION, callback: (transaction, key) => { @@ -25,10 +27,7 @@ Onyx.connect({ }, }); -/** - * @param {String} transactionID - */ -function createInitialWaypoints(transactionID) { +function createInitialWaypoints(transactionID: string) { Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, { comment: { waypoints: { @@ -41,14 +40,11 @@ function createInitialWaypoints(transactionID) { /** * Add a stop to the transaction - * - * @param {String} transactionID - * @param {Number} newLastIndex */ -function addStop(transactionID) { - const transaction = lodashGet(allTransactions, transactionID, {}); - const existingWaypoints = lodashGet(transaction, 'comment.waypoints', {}); - const newLastIndex = _.size(existingWaypoints); +function addStop(transactionID: string) { + const transaction = allTransactions?.[transactionID] ?? {}; + const existingWaypoints = transaction?.comment?.waypoints ?? {}; + const newLastIndex = Object.keys(existingWaypoints).length; Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, { comment: { @@ -59,14 +55,11 @@ function addStop(transactionID) { }); } -/** - * Saves the selected waypoint to the transaction - * @param {String} transactionID - * @param {String} index - * @param {Object} waypoint - */ -function saveWaypoint(transactionID, index, waypoint) { +function saveWaypoint(transactionID: string, index: string, waypoint: RecentWaypoint | null, isEditingWaypoint = false) { Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, { + pendingFields: { + comment: isEditingWaypoint ? CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE : CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + }, comment: { waypoints: { [`waypoint${index}`]: waypoint, @@ -93,33 +86,31 @@ function saveWaypoint(transactionID, index, waypoint) { if (!lodashHas(waypoint, 'lat') || !lodashHas(waypoint, 'lng')) { return; } - - const recentWaypointAlreadyExists = _.find(recentWaypoints, (recentWaypoint) => recentWaypoint.address === waypoint.address); - if (!recentWaypointAlreadyExists) { - const clonedWaypoints = _.clone(recentWaypoints); + const recentWaypointAlreadyExists = recentWaypoints.find((recentWaypoint) => recentWaypoint?.address === waypoint?.address); + if (!recentWaypointAlreadyExists && waypoint !== null) { + const clonedWaypoints = lodashClone(recentWaypoints); clonedWaypoints.unshift(waypoint); Onyx.merge(ONYXKEYS.NVP_RECENT_WAYPOINTS, clonedWaypoints.slice(0, 5)); } } -function removeWaypoint(transactionID, currentIndex) { +function removeWaypoint(transactionID: string, currentIndex: string) { // Index comes from the route params and is a string const index = Number(currentIndex); - const transaction = lodashGet(allTransactions, transactionID, {}); - const existingWaypoints = lodashGet(transaction, 'comment.waypoints', {}); - const totalWaypoints = _.size(existingWaypoints); - + const transaction = allTransactions?.[transactionID] ?? {}; + const existingWaypoints = transaction?.comment?.waypoints ?? {}; + const totalWaypoints = Object.keys(existingWaypoints).length; // Prevents removing the starting or ending waypoint but clear the stored address only if there are only two waypoints if (totalWaypoints === 2 && (index === 0 || index === totalWaypoints - 1)) { - saveWaypoint(transactionID, index, null); + saveWaypoint(transactionID, index.toString(), null); return; } - const waypointValues = _.values(existingWaypoints); + const waypointValues = Object.values(existingWaypoints); const removed = waypointValues.splice(index, 1); - const isRemovedWaypointEmpty = removed.length > 0 && !TransactionUtils.waypointHasValidAddress(removed[0]); + const isRemovedWaypointEmpty = removed.length > 0 && !TransactionUtils.waypointHasValidAddress(removed[0] ?? {}); - const reIndexedWaypoints = {}; + const reIndexedWaypoints: WaypointCollection = {}; waypointValues.forEach((waypoint, idx) => { reIndexedWaypoints[`waypoint${idx}`] = waypoint; }); @@ -127,7 +118,7 @@ function removeWaypoint(transactionID, currentIndex) { // Onyx.merge won't remove the null nested object values, this is a workaround // to remove nested keys while also preserving other object keys // Doing a deep clone of the transaction to avoid mutating the original object and running into a cache issue when using Onyx.set - let newTransaction = { + let newTransaction: Transaction = { ...transaction, comment: { ...transaction.comment, @@ -145,6 +136,7 @@ function removeWaypoint(transactionID, currentIndex) { // Clear the existing route so that we don't show an old route routes: { route0: { + distance: null, geometry: { coordinates: null, }, @@ -158,10 +150,8 @@ function removeWaypoint(transactionID, currentIndex) { /** * Gets the route for a set of waypoints * Used so we can generate a map view of the provided waypoints - * @param {String} transactionID - * @param {Object} waypoints */ -function getRoute(transactionID, waypoints) { +function getRoute(transactionID: string, waypoints: WaypointCollection) { API.read( 'GetRoute', { diff --git a/src/libs/actions/TransactionEdit.js b/src/libs/actions/TransactionEdit.js new file mode 100644 index 000000000000..44b489b72c43 --- /dev/null +++ b/src/libs/actions/TransactionEdit.js @@ -0,0 +1,38 @@ +import Onyx from 'react-native-onyx'; +import ONYXKEYS from '../../ONYXKEYS'; + +/** + * Makes a backup copy of a transaction object that can be restored when the user cancels editing a transaction. + * + * @param {Object} transaction + */ +function createBackupTransaction(transaction) { + const newTransaction = { + ...transaction, + }; + // Use set so that it will always fully overwrite any backup transaction that could have existed before + Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}-backup`, newTransaction); +} + +/** + * Removes a transaction from Onyx that was only used temporary in the edit flow + * @param {String} transactionID + */ +function removeBackupTransaction(transactionID) { + Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}-backup`, null); +} + +function restoreOriginalTransactionFromBackup(transactionID) { + const connectionID = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}-backup`, + callback: (backupTransaction) => { + Onyx.disconnect(connectionID); + + // Use set to completely overwrite the original transaction + Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, backupTransaction); + removeBackupTransaction(transactionID); + }, + }); +} + +export {createBackupTransaction, removeBackupTransaction, restoreOriginalTransactionFromBackup}; diff --git a/src/libs/actions/User.js b/src/libs/actions/User.js index fb6b3fe2bb12..1830d1e51f6f 100644 --- a/src/libs/actions/User.js +++ b/src/libs/actions/User.js @@ -471,8 +471,10 @@ function triggerNotifications(onyxUpdates) { const reportID = update.key.replace(ONYXKEYS.COLLECTION.REPORT_ACTIONS, ''); const reportActions = _.values(update.value); - const sortedReportActions = ReportActionsUtils.getSortedReportActions(reportActions); - Report.showReportActionNotification(reportID, _.last(sortedReportActions)); + + // eslint-disable-next-line rulesdir/no-negated-variables + const notifiableActions = _.filter(reportActions, (action) => ReportActionsUtils.isNotifiableReportAction(action)); + _.each(notifiableActions, (action) => Report.showReportActionNotification(reportID, action)); }); } diff --git a/src/libs/migrateOnyx.js b/src/libs/migrateOnyx.js index 4a115536e654..289f8913b534 100644 --- a/src/libs/migrateOnyx.js +++ b/src/libs/migrateOnyx.js @@ -1,7 +1,6 @@ import _ from 'underscore'; import Log from './Log'; import RenamePriorityModeKey from './migrations/RenamePriorityModeKey'; -import RenameExpensifyNewsStatus from './migrations/RenameExpensifyNewsStatus'; import AddLastVisibleActionCreated from './migrations/AddLastVisibleActionCreated'; import PersonalDetailsByAccountID from './migrations/PersonalDetailsByAccountID'; import RenameReceiptFilename from './migrations/RenameReceiptFilename'; @@ -12,7 +11,7 @@ export default function () { return new Promise((resolve) => { // Add all migrations to an array so they are executed in order - const migrationPromises = [RenamePriorityModeKey, RenameExpensifyNewsStatus, AddLastVisibleActionCreated, PersonalDetailsByAccountID, RenameReceiptFilename]; + const migrationPromises = [RenamePriorityModeKey, AddLastVisibleActionCreated, PersonalDetailsByAccountID, RenameReceiptFilename]; // Reduce all promises down to a single promise. All promises run in a linear fashion, waiting for the // previous promise to finish before moving onto the next one. diff --git a/src/libs/migrations/RenameExpensifyNewsStatus.js b/src/libs/migrations/RenameExpensifyNewsStatus.js deleted file mode 100644 index 421879b0e8ad..000000000000 --- a/src/libs/migrations/RenameExpensifyNewsStatus.js +++ /dev/null @@ -1,34 +0,0 @@ -import Onyx from 'react-native-onyx'; -import _ from 'underscore'; -import ONYXKEYS from '../../ONYXKEYS'; -import Log from '../Log'; - -// This migration changes the name of the Onyx key user.expensifyNewsStatus from expensifyNewsStatus to isSubscribedToNewsletter -export default function () { - return new Promise((resolve) => { - // Connect to the USER key in Onyx to get the value of expensifyNewsStatus - // then set that value as isSubscribedToNewsletter - // finally remove expensifyNewsStatus by setting the value to null - const connectionID = Onyx.connect({ - key: ONYXKEYS.USER, - callback: (user) => { - Onyx.disconnect(connectionID); - - // Fail early here because there is nothing to migrate - if (!user || _.isNull(user.expensifyNewsStatus) || _.isUndefined(user.expensifyNewsStatus)) { - Log.info('[Migrate Onyx] Skipped migration RenameExpensifyNewsStatus'); - return resolve(); - } - - // eslint-disable-next-line rulesdir/prefer-actions-set-data - Onyx.merge(ONYXKEYS.USER, { - expensifyNewsStatus: null, - isSubscribedToNewsletter: user.expensifyNewsStatus, - }).then(() => { - Log.info('[Migrate Onyx] Ran migration RenameExpensifyNewsStatus'); - resolve(); - }); - }, - }); - }); -} diff --git a/src/pages/DetailsPage.js b/src/pages/DetailsPage.js index 058ceef16b4c..2f8d42c686a9 100755 --- a/src/pages/DetailsPage.js +++ b/src/pages/DetailsPage.js @@ -155,6 +155,7 @@ function DetailsPage(props) { imageStyles={[styles.avatarLarge]} source={UserUtils.getAvatar(details.avatar, details.accountID)} size={CONST.AVATAR_SIZE.LARGE} + fallbackIcon={details.fallbackIcon} /> diff --git a/src/pages/EditRequestDistancePage.js b/src/pages/EditRequestDistancePage.js new file mode 100644 index 000000000000..84aa4de1acb0 --- /dev/null +++ b/src/pages/EditRequestDistancePage.js @@ -0,0 +1,127 @@ +import React, {useEffect, useRef} from 'react'; +import lodashGet from 'lodash/get'; +import PropTypes from 'prop-types'; +import _ from 'underscore'; +import {withOnyx} from 'react-native-onyx'; +import ONYXKEYS from '../ONYXKEYS'; +import CONST from '../CONST'; +import ScreenWrapper from '../components/ScreenWrapper'; +import HeaderWithBackButton from '../components/HeaderWithBackButton'; +import Navigation from '../libs/Navigation/Navigation'; +import useLocalize from '../hooks/useLocalize'; +import DistanceRequest from '../components/DistanceRequest'; +import reportPropTypes from './reportPropTypes'; +import * as IOU from '../libs/actions/IOU'; +import transactionPropTypes from '../components/transactionPropTypes'; +import * as TransactionEdit from '../libs/actions/TransactionEdit'; +import useNetwork from '../hooks/useNetwork'; +import usePrevious from '../hooks/usePrevious'; + +const propTypes = { + /** The transactionID we're currently editing */ + transactionID: PropTypes.string.isRequired, + + /** The report to with which the distance request is associated */ + report: reportPropTypes.isRequired, + + /** Passed from the navigator */ + route: PropTypes.shape({ + /** Parameters the route gets */ + params: PropTypes.shape({ + /** Type of IOU */ + iouType: PropTypes.oneOf(_.values(CONST.IOU.MONEY_REQUEST_TYPE)), + + /** Id of the report on which the distance request is being created */ + reportID: PropTypes.string, + }), + }).isRequired, + + /* Onyx props */ + /** The original transaction that is being edited */ + transaction: transactionPropTypes, +}; + +const defaultProps = { + transaction: {}, +}; + +function EditRequestDistancePage({report, route, transaction}) { + const {isOffline} = useNetwork(); + const {translate} = useLocalize(); + const transactionWasSaved = useRef(false); + const hasWaypointError = useRef(false); + const prevIsLoading = usePrevious(transaction.isLoading); + + useEffect(() => { + hasWaypointError.current = Boolean(lodashGet(transaction, 'errorFields.route') || lodashGet(transaction, 'errorFields.waypoints')); + + // When the loading goes from true to false, then we know the transaction has just been + // saved to the server. Check for errors. If there are no errors, then the modal can be closed. + if (prevIsLoading && !transaction.isLoading && !hasWaypointError.current) { + Navigation.dismissModal(report.reportID); + } + }, [transaction, prevIsLoading, report]); + + useEffect(() => { + // This effect runs when the component is mounted and unmounted. It's purpose is to be able to properly + // discard changes if the user cancels out of making any changes. This is accomplished by backing up the + // original transaction, letting the user modify the current transaction, and then if the user ever + // cancels out of the modal without saving changes, the original transaction is restored from the backup. + + // On mount, create the backup transaction. + TransactionEdit.createBackupTransaction(transaction); + + return () => { + // If the user cancels out of the modal without without saving changes, then the original transaction + // needs to be restored from the backup so that all changes are removed. + if (transactionWasSaved.current) { + return; + } + TransactionEdit.restoreOriginalTransactionFromBackup(transaction.transactionID); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + /** + * Save the changes to the original transaction object + * @param {Object} waypoints + */ + const saveTransaction = (waypoints) => { + transactionWasSaved.current = true; + IOU.updateDistanceRequest(transaction.transactionID, report.reportID, {waypoints}); + + // If the client is offline, then the modal can be closed as well (because there are no errors or other feedback to show them + // until they come online again and sync with the server). + if (isOffline) { + Navigation.dismissModal(report.reportID); + } + }; + + return ( + + Navigation.goBack()} + /> + + + ); +} + +EditRequestDistancePage.propTypes = propTypes; +EditRequestDistancePage.defaultProps = defaultProps; +EditRequestDistancePage.displayName = 'EditRequestDistancePage'; +export default withOnyx({ + transaction: { + key: (props) => `${ONYXKEYS.COLLECTION.TRANSACTION}${props.transactionID}`, + }, +})(EditRequestDistancePage); diff --git a/src/pages/EditRequestPage.js b/src/pages/EditRequestPage.js index 3e7297082088..90a32ec453f5 100644 --- a/src/pages/EditRequestPage.js +++ b/src/pages/EditRequestPage.js @@ -2,25 +2,29 @@ import React, {useEffect} from 'react'; import PropTypes from 'prop-types'; import lodashGet from 'lodash/get'; import {withOnyx} from 'react-native-onyx'; -import compose from '../libs/compose'; import CONST from '../CONST'; -import Navigation from '../libs/Navigation/Navigation'; import ONYXKEYS from '../ONYXKEYS'; +import compose from '../libs/compose'; +import Navigation from '../libs/Navigation/Navigation'; import * as ReportActionsUtils from '../libs/ReportActionsUtils'; import * as ReportUtils from '../libs/ReportUtils'; +import * as PolicyUtils from '../libs/PolicyUtils'; import * as TransactionUtils from '../libs/TransactionUtils'; import * as Policy from '../libs/actions/Policy'; +import * as IOU from '../libs/actions/IOU'; +import * as CurrencyUtils from '../libs/CurrencyUtils'; import withCurrentUserPersonalDetails, {withCurrentUserPersonalDetailsPropTypes} from '../components/withCurrentUserPersonalDetails'; +import tagPropTypes from '../components/tagPropTypes'; +import FullPageNotFoundView from '../components/BlockingViews/FullPageNotFoundView'; import EditRequestDescriptionPage from './EditRequestDescriptionPage'; import EditRequestMerchantPage from './EditRequestMerchantPage'; import EditRequestCreatedPage from './EditRequestCreatedPage'; import EditRequestAmountPage from './EditRequestAmountPage'; import EditRequestReceiptPage from './EditRequestReceiptPage'; import reportPropTypes from './reportPropTypes'; -import * as IOU from '../libs/actions/IOU'; -import * as CurrencyUtils from '../libs/CurrencyUtils'; -import FullPageNotFoundView from '../components/BlockingViews/FullPageNotFoundView'; +import EditRequestDistancePage from './EditRequestDistancePage'; import EditRequestCategoryPage from './EditRequestCategoryPage'; +import EditRequestTagPage from './EditRequestTagPage'; const propTypes = { /** Route from navigation */ @@ -35,6 +39,7 @@ const propTypes = { }), }).isRequired, + /** Onyx props */ /** The report object for the thread report */ report: reportPropTypes, @@ -56,6 +61,9 @@ const propTypes = { email: PropTypes.string, }), + /** Collection of tags attached to a policy */ + policyTags: tagPropTypes, + ...withCurrentUserPersonalDetailsPropTypes, }; @@ -66,9 +74,10 @@ const defaultProps = { session: { email: null, }, + policyTags: {}, }; -function EditRequestPage({report, route, parentReport, policy, session}) { +function EditRequestPage({report, route, parentReport, policy, session, policyTags}) { const parentReportAction = ReportActionsUtils.getParentReportAction(report); const transaction = TransactionUtils.getLinkedTransaction(parentReportAction); const { @@ -77,6 +86,7 @@ function EditRequestPage({report, route, parentReport, policy, session}) { comment: transactionDescription, merchant: transactionMerchant, category: transactionCategory, + tag: transactionTag, } = ReportUtils.getTransactionDetails(transaction); const defaultCurrency = lodashGet(route, 'params.currency', '') || transactionCurrency; @@ -92,6 +102,9 @@ function EditRequestPage({report, route, parentReport, policy, session}) { const isRequestor = ReportUtils.isMoneyRequestReport(parentReport) && lodashGet(session, 'accountID', null) === parentReportAction.actorAccountID; const canEdit = !isSettled && !isDeleted && (isAdmin || isRequestor); + // For now, it always defaults to the first tag of the policy + const tagListName = PolicyUtils.getTagListName(policyTags); + // Dismiss the modal when the request is paid or deleted useEffect(() => { if (canEdit) { @@ -104,8 +117,12 @@ function EditRequestPage({report, route, parentReport, policy, session}) { // Update the transaction object and close the modal function editMoneyRequest(transactionChanges) { - IOU.editMoneyRequest(transaction.transactionID, report.reportID, transactionChanges); - Navigation.dismissModal(); + if (TransactionUtils.isDistanceRequest(transaction)) { + IOU.updateDistanceRequest(transaction.transactionID, report.reportID, transactionChanges); + } else { + IOU.editMoneyRequest(transaction.transactionID, report.reportID, transactionChanges); + } + Navigation.dismissModal(report.reportID); } if (fieldToEdit === CONST.EDIT_REQUEST_FIELD.DESCRIPTION) { @@ -196,6 +213,25 @@ function EditRequestPage({report, route, parentReport, policy, session}) { ); } + if (fieldToEdit === CONST.EDIT_REQUEST_FIELD.TAG) { + return ( + { + let updatedTag = transactionChanges.tag; + + // In case the same tag has been selected, reset the tag. + if (transactionTag === updatedTag) { + updatedTag = ''; + } + editMoneyRequest({tag: updatedTag, tagListName}); + }} + /> + ); + } + if (fieldToEdit === CONST.EDIT_REQUEST_FIELD.RECEIPT) { return ( + ); + } + return ; } @@ -226,5 +272,8 @@ export default compose( policy: { key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY}${report ? report.policyID : '0'}`, }, + policyTags: { + key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY_TAGS}${report ? report.policyID : '0'}`, + }, }), )(EditRequestPage); diff --git a/src/pages/EditRequestTagPage.js b/src/pages/EditRequestTagPage.js new file mode 100644 index 000000000000..72ed072eec16 --- /dev/null +++ b/src/pages/EditRequestTagPage.js @@ -0,0 +1,53 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import Navigation from '../libs/Navigation/Navigation'; +import useLocalize from '../hooks/useLocalize'; +import ScreenWrapper from '../components/ScreenWrapper'; +import HeaderWithBackButton from '../components/HeaderWithBackButton'; +import TagPicker from '../components/TagPicker'; + +const propTypes = { + /** Transaction default tag value */ + defaultTag: PropTypes.string.isRequired, + + /** The policyID we are getting tags for */ + policyID: PropTypes.string.isRequired, + + /** The tag name to which the default tag belongs to */ + tagName: PropTypes.string.isRequired, + + /** Callback to fire when the Save button is pressed */ + onSubmit: PropTypes.func.isRequired, +}; + +function EditRequestTagPage({defaultTag, policyID, tagName, onSubmit}) { + const {translate} = useLocalize(); + + const selectTag = (tag) => { + onSubmit({tag: tag.searchText}); + }; + + return ( + + + + + + ); +} + +EditRequestTagPage.propTypes = propTypes; +EditRequestTagPage.displayName = 'EditRequestTagPage'; + +export default EditRequestTagPage; diff --git a/src/pages/PrivateNotes/PrivateNotesListPage.js b/src/pages/PrivateNotes/PrivateNotesListPage.js index ab096285788d..e4159119dcb3 100644 --- a/src/pages/PrivateNotes/PrivateNotesListPage.js +++ b/src/pages/PrivateNotes/PrivateNotesListPage.js @@ -3,6 +3,7 @@ import {withOnyx} from 'react-native-onyx'; import PropTypes from 'prop-types'; import _ from 'underscore'; import lodashGet from 'lodash/get'; +import {ScrollView} from 'react-native'; import Navigation from '../../libs/Navigation/Navigation'; import ONYXKEYS from '../../ONYXKEYS'; import CONST from '../../CONST'; @@ -127,11 +128,13 @@ function PrivateNotesListPage({report, personalDetailsList, network, session}) { shouldShowBackButton onCloseButtonPress={() => Navigation.dismissModal()} /> - {report.isLoadingPrivateNotes && _.isEmpty(lodashGet(report, 'privateNotes', {})) ? ( - - ) : ( - _.map(privateNotes, (item, index) => getMenuItem(item, index)) - )} + + {report.isLoadingPrivateNotes && _.isEmpty(lodashGet(report, 'privateNotes', {})) ? ( + + ) : ( + _.map(privateNotes, (item, index) => getMenuItem(item, index)) + )} + ); diff --git a/src/pages/ProfilePage.js b/src/pages/ProfilePage.js index 60037e4755c0..7e93f4d99be2 100755 --- a/src/pages/ProfilePage.js +++ b/src/pages/ProfilePage.js @@ -103,6 +103,7 @@ function ProfilePage(props) { const displayName = details.displayName ? details.displayName : props.translate('common.hidden'); const avatar = lodashGet(details, 'avatar', UserUtils.getDefaultAvatar()); + const fallbackIcon = lodashGet(details, 'fallbackIcon', ''); const originalFileName = lodashGet(details, 'originalFileName', ''); const login = lodashGet(details, 'login', ''); const timezone = lodashGet(details, 'timezone', {}); @@ -161,6 +162,7 @@ function ProfilePage(props) { source={UserUtils.getFullSizeAvatar(avatar, accountID)} isAuthTokenRequired originalFileName={originalFileName} + fallbackSource={fallbackIcon} > {({show}) => ( diff --git a/src/pages/ReimbursementAccount/BankAccountStep.js b/src/pages/ReimbursementAccount/BankAccountStep.js index de5ed03ed823..b01b46f69350 100644 --- a/src/pages/ReimbursementAccount/BankAccountStep.js +++ b/src/pages/ReimbursementAccount/BankAccountStep.js @@ -70,7 +70,11 @@ function BankAccountStep(props) { subStep = CONST.BANK_ACCOUNT.SETUP_TYPE.PLAID; } const plaidDesktopMessage = getPlaidDesktopMessage(); - const bankAccountRoute = `${CONFIG.EXPENSIFY.NEW_EXPENSIFY_URL}${ROUTES.getBankAccountRoute('new', props.policyID, ROUTES.getWorkspaceInitialRoute(props.policyID))}`; + const bankAccountRoute = `${CONFIG.EXPENSIFY.NEW_EXPENSIFY_URL}${ROUTES.BANK_ACCOUNT_WITH_STEP_TO_OPEN.getRoute( + 'new', + props.policyID, + ROUTES.WORKSPACE_INITIAL.getRoute(props.policyID), + )}`; if (subStep === CONST.BANK_ACCOUNT.SETUP_TYPE.MANUAL) { return ( diff --git a/src/pages/ReimbursementAccount/RequestorStep.js b/src/pages/ReimbursementAccount/RequestorStep.js index cb1d306eebb6..53ca279c2cb2 100644 --- a/src/pages/ReimbursementAccount/RequestorStep.js +++ b/src/pages/ReimbursementAccount/RequestorStep.js @@ -1,9 +1,8 @@ -import React from 'react'; +import React, {useCallback, useMemo} from 'react'; import {View} from 'react-native'; import PropTypes from 'prop-types'; -import lodashGet from 'lodash/get'; +import _ from 'lodash'; import styles from '../../styles/styles'; -import withLocalize from '../../components/withLocalize'; import HeaderWithBackButton from '../../components/HeaderWithBackButton'; import CONST from '../../CONST'; import TextLink from '../../components/TextLink'; @@ -16,182 +15,188 @@ import ONYXKEYS from '../../ONYXKEYS'; import RequestorOnfidoStep from './RequestorOnfidoStep'; import Form from '../../components/Form'; import ScreenWrapper from '../../components/ScreenWrapper'; -import StepPropTypes from './StepPropTypes'; +import useLocalize from '../../hooks/useLocalize'; +import {reimbursementAccountPropTypes} from './reimbursementAccountPropTypes'; +import ReimbursementAccountDraftPropTypes from './ReimbursementAccountDraftPropTypes'; const propTypes = { - ...StepPropTypes, + onBackButtonPress: PropTypes.func.isRequired, + getDefaultStateForField: PropTypes.func.isRequired, + reimbursementAccount: reimbursementAccountPropTypes.isRequired, + reimbursementAccountDraft: ReimbursementAccountDraftPropTypes.isRequired, /** If we should show Onfido flow */ shouldShowOnfido: PropTypes.bool.isRequired, }; -class RequestorStep extends React.Component { - constructor(props) { - super(props); - - this.validate = this.validate.bind(this); - this.submit = this.submit.bind(this); - } - - /** - * @param {Object} values - * @returns {Object} - */ - validate(values) { - const requiredFields = ['firstName', 'lastName', 'dob', 'ssnLast4', 'requestorAddressStreet', 'requestorAddressCity', 'requestorAddressState', 'requestorAddressZipCode']; - const errors = ValidationUtils.getFieldRequiredErrors(values, requiredFields); - - if (values.dob) { - if (!ValidationUtils.isValidPastDate(values.dob) || !ValidationUtils.meetsMaximumAgeRequirement(values.dob)) { - errors.dob = 'bankAccount.error.dob'; - } else if (!ValidationUtils.meetsMinimumAgeRequirement(values.dob)) { - errors.dob = 'bankAccount.error.age'; - } - } - - if (values.ssnLast4 && !ValidationUtils.isValidSSNLastFour(values.ssnLast4)) { - errors.ssnLast4 = 'bankAccount.error.ssnLast4'; - } +const REQUIRED_FIELDS = ['firstName', 'lastName', 'dob', 'ssnLast4', 'requestorAddressStreet', 'requestorAddressCity', 'requestorAddressState', 'requestorAddressZipCode']; +const INPUT_KEYS = { + firstName: 'firstName', + lastName: 'lastName', + dob: 'dob', + ssnLast4: 'ssnLast4', + street: 'requestorAddressStreet', + city: 'requestorAddressCity', + state: 'requestorAddressState', + zipCode: 'requestorAddressZipCode', +}; +const STEP_COUNTER = {step: 3, total: 5}; - if (values.requestorAddressStreet && !ValidationUtils.isValidAddress(values.requestorAddressStreet)) { - errors.requestorAddressStreet = 'bankAccount.error.addressStreet'; - } +const validate = (values) => { + const errors = ValidationUtils.getFieldRequiredErrors(values, REQUIRED_FIELDS); - if (values.requestorAddressZipCode && !ValidationUtils.isValidZipCode(values.requestorAddressZipCode)) { - errors.requestorAddressZipCode = 'bankAccount.error.zipCode'; + if (values.dob) { + if (!ValidationUtils.isValidPastDate(values.dob) || !ValidationUtils.meetsMaximumAgeRequirement(values.dob)) { + errors.dob = 'bankAccount.error.dob'; + } else if (!ValidationUtils.meetsMinimumAgeRequirement(values.dob)) { + errors.dob = 'bankAccount.error.age'; } + } - if (!ValidationUtils.isRequiredFulfilled(values.isControllingOfficer)) { - errors.isControllingOfficer = 'requestorStep.isControllingOfficerError'; - } + if (values.ssnLast4 && !ValidationUtils.isValidSSNLastFour(values.ssnLast4)) { + errors.ssnLast4 = 'bankAccount.error.ssnLast4'; + } - return errors; + if (values.requestorAddressStreet && !ValidationUtils.isValidAddress(values.requestorAddressStreet)) { + errors.requestorAddressStreet = 'bankAccount.error.addressStreet'; } - submit(values) { - const payload = { - bankAccountID: lodashGet(this.props.reimbursementAccount, 'achData.bankAccountID') || 0, - ...values, - }; + if (values.requestorAddressZipCode && !ValidationUtils.isValidZipCode(values.requestorAddressZipCode)) { + errors.requestorAddressZipCode = 'bankAccount.error.zipCode'; + } - BankAccounts.updatePersonalInformationForBankAccount(payload); + if (!ValidationUtils.isRequiredFulfilled(values.isControllingOfficer)) { + errors.isControllingOfficer = 'requestorStep.isControllingOfficerError'; } - render() { - if (this.props.shouldShowOnfido) { - return ( - - ); - } + return errors; +}; +function RequestorStep({reimbursementAccount, shouldShowOnfido, reimbursementAccountDraft, onBackButtonPress, getDefaultStateForField}) { + const {translate} = useLocalize(); + + const defaultValues = useMemo( + () => ({ + firstName: getDefaultStateForField(INPUT_KEYS.firstName), + lastName: getDefaultStateForField(INPUT_KEYS.lastName), + street: getDefaultStateForField(INPUT_KEYS.street), + city: getDefaultStateForField(INPUT_KEYS.city), + state: getDefaultStateForField(INPUT_KEYS.state), + zipCode: getDefaultStateForField(INPUT_KEYS.zipCode), + dob: getDefaultStateForField(INPUT_KEYS.dob), + ssnLast4: getDefaultStateForField(INPUT_KEYS.ssnLast4), + }), + [getDefaultStateForField], + ); + + const submit = useCallback( + (values) => { + const payload = { + bankAccountID: _.get(reimbursementAccount, 'achData.bankAccountID', 0), + ...values, + }; + + BankAccounts.updatePersonalInformationForBankAccount(payload); + }, + [reimbursementAccount], + ); + + const renderLabelComponent = () => ( + + {translate('requestorStep.isControllingOfficer')} + + ); + + if (shouldShowOnfido) { return ( - - -
      - {this.props.translate('requestorStep.subtitle')} - - - {`${this.props.translate('requestorStep.learnMore')}`} - - {' | '} - - {`${this.props.translate('requestorStep.isMyDataSafe')}`} - - - - ( - - {this.props.translate('requestorStep.isControllingOfficer')} - - )} - style={[styles.mt4]} - shouldSaveDraft - /> - - {this.props.translate('requestorStep.onFidoConditions')} - - {this.props.translate('onfidoStep.facialScan')} - - {', '} - - {this.props.translate('common.privacy')} - - {` ${this.props.translate('common.and')} `} - - {this.props.translate('common.termsOfService')} - - -
      -
      + ); } + + return ( + + +
      + {translate('requestorStep.subtitle')} + + + {translate('requestorStep.learnMore')} + + {' | '} + + {translate('requestorStep.isMyDataSafe')} + + + + + + {translate('requestorStep.onFidoConditions')} + + {translate('onfidoStep.facialScan')} + + {', '} + + {translate('common.privacy')} + + {` ${translate('common.and')} `} + + {translate('common.termsOfService')} + + +
      +
      + ); } RequestorStep.propTypes = propTypes; +RequestorStep.displayName = 'RequestorStep'; -export default withLocalize(RequestorStep); +export default React.forwardRef(RequestorStep); diff --git a/src/pages/ValidateLoginPage/index.js b/src/pages/ValidateLoginPage/index.js index 561c8512ca3d..0af978172822 100644 --- a/src/pages/ValidateLoginPage/index.js +++ b/src/pages/ValidateLoginPage/index.js @@ -6,7 +6,6 @@ import {propTypes as validateLinkPropTypes, defaultProps as validateLinkDefaultP import FullScreenLoadingIndicator from '../../components/FullscreenLoadingIndicator'; import ONYXKEYS from '../../ONYXKEYS'; import * as Session from '../../libs/actions/Session'; -import useLocalize from '../../hooks/useLocalize'; import Navigation from '../../libs/Navigation/Navigation'; const propTypes = { @@ -35,8 +34,6 @@ const defaultProps = { }; function ValidateLoginPage(props) { - const {preferredLocale} = useLocalize(); - useEffect(() => { const accountID = lodashGet(props.route.params, 'accountID', ''); const validateCode = lodashGet(props.route.params, 'validateCode', ''); @@ -46,7 +43,7 @@ function ValidateLoginPage(props) { // because we don't want to block the user with the interstitial page. Navigation.goBack(false); } else { - Session.signInWithValidateCodeAndNavigate(accountID, validateCode, preferredLocale); + Session.signInWithValidateCodeAndNavigate(accountID, validateCode); } // eslint-disable-next-line react-hooks/exhaustive-deps }, []); diff --git a/src/pages/ValidateLoginPage/index.website.js b/src/pages/ValidateLoginPage/index.website.js index 323779a7d420..4ac12b3193f3 100644 --- a/src/pages/ValidateLoginPage/index.website.js +++ b/src/pages/ValidateLoginPage/index.website.js @@ -7,7 +7,6 @@ import FullScreenLoadingIndicator from '../../components/FullscreenLoadingIndica import ValidateCodeModal from '../../components/ValidateCode/ValidateCodeModal'; import ONYXKEYS from '../../ONYXKEYS'; import * as Session from '../../libs/actions/Session'; -import useLocalize from '../../hooks/useLocalize'; import ExpiredValidateCodeModal from '../../components/ValidateCode/ExpiredValidateCodeModal'; import Navigation from '../../libs/Navigation/Navigation'; import CONST from '../../CONST'; @@ -49,7 +48,6 @@ const defaultProps = { }; function ValidateLoginPage(props) { - const {preferredLocale} = useLocalize(); const login = lodashGet(props, 'credentials.login', null); const autoAuthState = lodashGet(props, 'session.autoAuthState', CONST.AUTO_AUTH_STATE.NOT_STARTED); const accountID = lodashGet(props.route.params, 'accountID', ''); @@ -71,7 +69,7 @@ function ValidateLoginPage(props) { } // The user has initiated the sign in process on the same browser, in another tab. - Session.signInWithValidateCode(accountID, validateCode, preferredLocale); + Session.signInWithValidateCode(accountID, validateCode); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); diff --git a/src/pages/home/HeaderView.js b/src/pages/home/HeaderView.js index 477d063c1747..ba4787f9b4b4 100644 --- a/src/pages/home/HeaderView.js +++ b/src/pages/home/HeaderView.js @@ -25,7 +25,6 @@ import reportPropTypes from '../reportPropTypes'; import ONYXKEYS from '../../ONYXKEYS'; import ThreeDotsMenu from '../../components/ThreeDotsMenu'; import * as Task from '../../libs/actions/Task'; -import reportActionPropTypes from './report/reportActionPropTypes'; import PressableWithoutFeedback from '../../components/Pressable/PressableWithoutFeedback'; import PinButton from '../../components/PinButton'; import TaskHeaderActionButton from '../../components/TaskHeaderActionButton'; @@ -45,33 +44,22 @@ const propTypes = { /** Onyx Props */ parentReport: reportPropTypes, - /** The details about the account that the user is signing in with */ - account: PropTypes.shape({ - /** URL to the assigned guide's appointment booking calendar */ - guideCalendarLink: PropTypes.string, - }), + /** URL to the assigned guide's appointment booking calendar */ + guideCalendarLink: PropTypes.string, /** Current user session */ session: PropTypes.shape({ accountID: PropTypes.number, }), - /** The report actions from the parent report */ - // TO DO: Replace with HOC https://github.com/Expensify/App/issues/18769. - // eslint-disable-next-line react/no-unused-prop-types - parentReportActions: PropTypes.objectOf(PropTypes.shape(reportActionPropTypes)), - ...windowDimensionsPropTypes, ...withLocalizePropTypes, }; const defaultProps = { personalDetails: {}, - parentReportActions: {}, report: null, - account: { - guideCalendarLink: null, - }, + guideCalendarLink: null, parentReport: {}, session: { accountID: 0, @@ -93,21 +81,20 @@ function HeaderView(props) { const parentNavigationSubtitleData = ReportUtils.getParentNavigationSubtitle(reportHeaderData); const isConcierge = ReportUtils.hasSingleParticipant(props.report) && _.contains(participants, CONST.ACCOUNT_ID.CONCIERGE); const isAutomatedExpensifyAccount = ReportUtils.hasSingleParticipant(props.report) && ReportUtils.hasAutomatedExpensifyAccountIDs(participants); - const guideCalendarLink = lodashGet(props.account, 'guideCalendarLink'); const parentReportAction = ReportActionsUtils.getParentReportAction(props.report); const isCanceledTaskReport = ReportUtils.isCanceledTaskReport(props.report, parentReportAction); // We hide the button when we are chatting with an automated Expensify account since it's not possible to contact // these users via alternative means. It is possible to request a call with Concierge so we leave the option for them. - const shouldShowCallButton = (isConcierge && guideCalendarLink) || (!isAutomatedExpensifyAccount && !isTaskReport); + const shouldShowCallButton = (isConcierge && props.guideCalendarLink) || (!isAutomatedExpensifyAccount && !isTaskReport); const threeDotMenuItems = []; if (isTaskReport && !isCanceledTaskReport) { const canModifyTask = Task.canModifyTask(props.report, props.session.accountID); if (ReportUtils.isOpenTaskReport(props.report) && canModifyTask) { threeDotMenuItems.push({ icon: Expensicons.Checkmark, - text: props.translate('task.markAsDone'), - onSelected: () => Task.completeTask(props.report, title), + text: props.translate('task.markAsComplete'), + onSelected: () => Task.completeTask(props.report), }); } @@ -116,7 +103,7 @@ function HeaderView(props) { threeDotMenuItems.push({ icon: Expensicons.Checkmark, text: props.translate('task.markAsIncomplete'), - onSelected: () => Task.reopenTask(props.report, title), + onSelected: () => Task.reopenTask(props.report), }); } @@ -222,7 +209,7 @@ function HeaderView(props) { {shouldShowCallButton && ( )} @@ -247,17 +234,10 @@ export default compose( withWindowDimensions, withLocalize, withOnyx({ - account: { + guideCalendarLink: { key: ONYXKEYS.ACCOUNT, - selector: (account) => - account && { - guideCalendarLink: account.guideCalendarLink, - primaryLogin: account.primaryLogin, - }, - }, - parentReportActions: { - key: ({report}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.parentReportID}`, - canEvict: false, + selector: (account) => (account && account.guideCalendarLink) || null, + initialValue: null, }, parentReport: { key: ({report}) => `${ONYXKEYS.COLLECTION.REPORT}${report.parentReportID || report.reportID}`, diff --git a/src/pages/home/ReportScreen.js b/src/pages/home/ReportScreen.js index 15bf25695fd3..c30a8c7ed4a8 100644 --- a/src/pages/home/ReportScreen.js +++ b/src/pages/home/ReportScreen.js @@ -16,15 +16,15 @@ import * as ReportUtils from '../../libs/ReportUtils'; import ReportActionsView from './report/ReportActionsView'; import ReportActionsSkeletonView from '../../components/ReportActionsSkeletonView'; import reportActionPropTypes from './report/reportActionPropTypes'; -import useNetwork from '../../hooks/useNetwork'; -import useWindowDimensions from '../../hooks/useWindowDimensions'; -import useLocalize from '../../hooks/useLocalize'; import compose from '../../libs/compose'; import Visibility from '../../libs/Visibility'; +import useWindowDimensions from '../../hooks/useWindowDimensions'; +import useLocalize from '../../hooks/useLocalize'; import OfflineWithFeedback from '../../components/OfflineWithFeedback'; import ReportFooter from './report/ReportFooter'; import Banner from '../../components/Banner'; import reportPropTypes from '../reportPropTypes'; +import reportMetadataPropTypes from '../reportMetadataPropTypes'; import FullPageNotFoundView from '../../components/BlockingViews/FullPageNotFoundView'; import withViewportOffsetTop, {viewportOffsetTopPropTypes} from '../../components/withViewportOffsetTop'; import * as ReportActionsUtils from '../../libs/ReportActionsUtils'; @@ -33,7 +33,7 @@ import getIsReportFullyVisible from '../../libs/getIsReportFullyVisible'; import MoneyRequestHeader from '../../components/MoneyRequestHeader'; import MoneyReportHeader from '../../components/MoneyReportHeader'; import * as ComposerActions from '../../libs/actions/Composer'; -import ReportScreenContext from './ReportScreenContext'; +import {ActionListContext, ReactionListContext} from './ReportScreenContext'; import TaskHeaderActionButton from '../../components/TaskHeaderActionButton'; import DragAndDropProvider from '../../components/DragAndDrop/Provider'; import usePrevious from '../../hooks/usePrevious'; @@ -59,6 +59,9 @@ const propTypes = { /** The report currently being looked at */ report: reportPropTypes, + /** The report metadata loading states */ + reportMetadata: reportMetadataPropTypes, + /** Array of report actions for this report */ reportActions: PropTypes.arrayOf(PropTypes.shape(reportActionPropTypes)), @@ -85,6 +88,9 @@ const propTypes = { /** All of the personal details for everyone */ personalDetails: PropTypes.objectOf(personalDetailsPropType), + /** Onyx function that marks the component ready for hydration */ + markReadyForHydration: PropTypes.func, + /** Whether user is leaving the current report */ userLeavingStatus: PropTypes.bool, @@ -97,7 +103,10 @@ const defaultProps = { reportActions: [], report: { hasOutstandingIOU: false, + }, + reportMetadata: { isLoadingReportActions: false, + isLoadingMoreReportActions: false, }, isComposerFullSize: false, betas: [], @@ -105,6 +114,7 @@ const defaultProps = { accountManagerReportID: null, userLeavingStatus: false, personalDetails: {}, + markReadyForHydration: null, ...withCurrentReportIDDefaultProps, }; @@ -133,9 +143,11 @@ function ReportScreen({ betas, route, report, + reportMetadata, reportActions, accountManagerReportID, personalDetails, + markReadyForHydration, policies, isSidebarLoaded, viewportOffsetTop, @@ -145,7 +157,6 @@ function ReportScreen({ currentReportID, }) { const {translate} = useLocalize(); - const {isOffline} = useNetwork(); const {isSmallScreenWidth} = useWindowDimensions(); const firstRenderRef = useRef(true); @@ -153,8 +164,6 @@ function ReportScreen({ const reactionListRef = useRef(); const prevReport = usePrevious(report); const prevUserLeavingStatus = usePrevious(userLeavingStatus); - - const [skeletonViewContainerHeight, setSkeletonViewContainerHeight] = useState(0); const [isBannerVisible, setIsBannerVisible] = useState(true); const reportID = getReportID(route); @@ -162,19 +171,19 @@ function ReportScreen({ const screenWrapperStyle = [styles.appContent, styles.flex1, {marginTop: viewportOffsetTop}]; // There are no reportActions at all to display and we are still in the process of loading the next set of actions. - const isLoadingInitialReportActions = _.isEmpty(reportActions) && report.isLoadingReportActions; + const isLoadingInitialReportActions = _.isEmpty(reportActions) && reportMetadata.isLoadingReportActions; const isOptimisticDelete = lodashGet(report, 'statusNum') === CONST.REPORT.STATUS.CLOSED; const shouldHideReport = !ReportUtils.canAccessReport(report, policies, betas); - const isLoading = !reportID || !isSidebarLoaded || _.isEmpty(personalDetails) || firstRenderRef.current; + const isLoading = !reportID || !isSidebarLoaded || _.isEmpty(personalDetails); const parentReportAction = ReportActionsUtils.getParentReportAction(report); const isDeletedParentAction = ReportActionsUtils.isDeletedParentAction(parentReportAction); const isSingleTransactionView = ReportUtils.isMoneyRequest(report); - const policy = policies[`${ONYXKEYS.COLLECTION.POLICY}${report.policyID}`]; + const policy = policies[`${ONYXKEYS.COLLECTION.POLICY}${report.policyID}`] || {}; const isTopMostReportId = currentReportID === getReportID(route); const didSubscribeToReportLeavingEvents = useRef(false); @@ -344,112 +353,112 @@ function ReportScreen({ } }, [report, didSubscribeToReportLeavingEvents, reportID]); + const onListLayout = useCallback(() => { + if (!markReadyForHydration) { + return; + } + + markReadyForHydration(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + // eslint-disable-next-line rulesdir/no-negated-variables const shouldShowNotFoundPage = useMemo( - () => (!_.isEmpty(report) && !isDefaultReport && !report.reportID && !isOptimisticDelete && !report.isLoadingReportActions && !isLoading && !userLeavingStatus) || shouldHideReport, + () => + (!firstRenderRef.current && + !_.isEmpty(report) && + !isDefaultReport && + !report.reportID && + !isOptimisticDelete && + !report.isLoadingReportActions && + !isLoading && + !userLeavingStatus) || + shouldHideReport, [report, isLoading, shouldHideReport, isDefaultReport, isOptimisticDelete, userLeavingStatus], ); return ( - - - + + - - {headerView} - {ReportUtils.isTaskReport(report) && isSmallScreenWidth && ReportUtils.isOpenTaskReport(report, parentReportAction) && ( - - - - + + {headerView} + {ReportUtils.isTaskReport(report) && isSmallScreenWidth && ReportUtils.isOpenTaskReport(report, parentReportAction) && ( + + + + + - - )} - - {Boolean(accountManagerReportID) && ReportUtils.isConciergeChatReport(report) && isBannerVisible && ( - - )} - - { - // Rounding this value for comparison because they can look like this: 411.9999694824219 - const newSkeletonViewContainerHeight = Math.round(event.nativeEvent.layout.height); - - // The height can be 0 if the component unmounts - we are not interested in this value and want to know how much space it - // takes up so we can set the skeleton view container height. - if (newSkeletonViewContainerHeight === 0) { - return; - } - setSkeletonViewContainerHeight(newSkeletonViewContainerHeight); - }} - > - {isReportReadyForDisplay && !isLoadingInitialReportActions && !isLoading && ( - )} + + {!!accountManagerReportID && ReportUtils.isConciergeChatReport(report) && isBannerVisible && ( + + )} + + + {isReportReadyForDisplay && !isLoadingInitialReportActions && !isLoading && ( + + )} - {/* Note: The report should be allowed to mount even if the initial report actions are not loaded. If we prevent rendering the report while they are loading then - we'll unnecessarily unmount the ReportActionsView which will clear the new marker lines initial state. */} - {(!isReportReadyForDisplay || isLoadingInitialReportActions || isLoading) && } + {/* Note: The ReportActionsSkeletonView should be allowed to mount even if the initial report actions are not loaded. + If we prevent rendering the report while they are loading then + we'll unnecessarily unmount the ReportActionsView which will clear the new marker lines initial state. */} + {(!isReportReadyForDisplay || isLoadingInitialReportActions || isLoading) && } - {isReportReadyForDisplay && ( - <> + {isReportReadyForDisplay ? ( - - )} - - {!isReportReadyForDisplay && ( - - )} - - - - - + ) : ( + + )} +
      + + + + + ); } @@ -460,35 +469,50 @@ ReportScreen.displayName = 'ReportScreen'; export default compose( withViewportOffsetTop, withCurrentReportID, - withOnyx({ - isSidebarLoaded: { - key: ONYXKEYS.IS_SIDEBAR_LOADED, + withOnyx( + { + isSidebarLoaded: { + key: ONYXKEYS.IS_SIDEBAR_LOADED, + }, + reportActions: { + key: ({route}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${getReportID(route)}`, + canEvict: false, + selector: ReportActionsUtils.getSortedReportActionsForDisplay, + }, + report: { + key: ({route}) => `${ONYXKEYS.COLLECTION.REPORT}${getReportID(route)}`, + allowStaleData: true, + }, + reportMetadata: { + key: ({route}) => `${ONYXKEYS.COLLECTION.REPORT_METADATA}${getReportID(route)}`, + initialValue: { + isLoadingReportActions: false, + isLoadingMoreReportActions: false, + }, + }, + isComposerFullSize: { + key: ({route}) => `${ONYXKEYS.COLLECTION.REPORT_IS_COMPOSER_FULL_SIZE}${getReportID(route)}`, + initialValue: false, + }, + betas: { + key: ONYXKEYS.BETAS, + }, + policies: { + key: ONYXKEYS.COLLECTION.POLICY, + allowStaleData: true, + }, + accountManagerReportID: { + key: ONYXKEYS.ACCOUNT_MANAGER_REPORT_ID, + initialValue: null, + }, + personalDetails: { + key: ONYXKEYS.PERSONAL_DETAILS_LIST, + }, + userLeavingStatus: { + key: ({route}) => `${ONYXKEYS.COLLECTION.REPORT_USER_IS_LEAVING_ROOM}${getReportID(route)}`, + initialValue: false, + }, }, - reportActions: { - key: ({route}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${getReportID(route)}`, - canEvict: false, - selector: ReportActionsUtils.getSortedReportActionsForDisplay, - }, - report: { - key: ({route}) => `${ONYXKEYS.COLLECTION.REPORT}${getReportID(route)}`, - }, - isComposerFullSize: { - key: ({route}) => `${ONYXKEYS.COLLECTION.REPORT_IS_COMPOSER_FULL_SIZE}${getReportID(route)}`, - }, - betas: { - key: ONYXKEYS.BETAS, - }, - policies: { - key: ONYXKEYS.COLLECTION.POLICY, - }, - accountManagerReportID: { - key: ONYXKEYS.ACCOUNT_MANAGER_REPORT_ID, - }, - personalDetails: { - key: ONYXKEYS.PERSONAL_DETAILS_LIST, - }, - userLeavingStatus: { - key: ({route}) => `${ONYXKEYS.COLLECTION.REPORT_USER_IS_LEAVING_ROOM}${getReportID(route)}`, - }, - }), + true, + ), )(ReportScreen); diff --git a/src/pages/home/ReportScreenContext.js b/src/pages/home/ReportScreenContext.js index 2f79d6ae9432..1e8d30cf7585 100644 --- a/src/pages/home/ReportScreenContext.js +++ b/src/pages/home/ReportScreenContext.js @@ -1,4 +1,6 @@ import {createContext} from 'react'; -const ReportScreenContext = createContext(); -export default ReportScreenContext; +const ActionListContext = createContext(); +const ReactionListContext = createContext(); + +export {ActionListContext, ReactionListContext}; diff --git a/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.js b/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.js index fc56b3b1fac9..91fe38784e9c 100755 --- a/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.js +++ b/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.js @@ -18,6 +18,7 @@ import ONYXKEYS from '../../../../ONYXKEYS'; import CONST from '../../../../CONST'; import useArrowKeyFocusManager from '../../../../hooks/useArrowKeyFocusManager'; import useKeyboardShortcut from '../../../../hooks/useKeyboardShortcut'; +import useNetwork from '../../../../hooks/useNetwork'; const propTypes = { /** String representing the context menu type [LINK, REPORT_ACTION] which controls context menu choices */ @@ -51,6 +52,7 @@ function BaseReportActionContextMenu(props) { const menuItemRefs = useRef({}); const [shouldKeepOpen, setShouldKeepOpen] = useState(false); const wrapperStyle = getReportActionContextMenuStyles(props.isMini, props.isSmallScreenWidth); + const {isOffline} = useNetwork(); const reportAction = useMemo(() => { if (_.isEmpty(props.reportActions) || props.reportActionID === '0') { @@ -60,7 +62,18 @@ function BaseReportActionContextMenu(props) { }, [props.reportActions, props.reportActionID]); const shouldShowFilter = (contextAction) => - contextAction.shouldShow(props.type, reportAction, props.isArchivedRoom, props.betas, props.anchor, props.isChronosReport, props.reportID, props.isPinnedChat, props.isUnreadChat); + contextAction.shouldShow( + props.type, + reportAction, + props.isArchivedRoom, + props.betas, + props.anchor, + props.isChronosReport, + props.reportID, + props.isPinnedChat, + props.isUnreadChat, + isOffline, + ); const shouldEnableArrowNavigation = !props.isMini && (props.isVisible || shouldKeepOpen); const filteredContextMenuActions = _.filter(ContextMenuActions, shouldShowFilter); diff --git a/src/pages/home/report/ContextMenu/ContextMenuActions.js b/src/pages/home/report/ContextMenu/ContextMenuActions.js index 2a65bc2e67ab..157ae66dc918 100644 --- a/src/pages/home/report/ContextMenu/ContextMenuActions.js +++ b/src/pages/home/report/ContextMenu/ContextMenuActions.js @@ -22,9 +22,6 @@ import MiniQuickEmojiReactions from '../../../../components/Reactions/MiniQuickE import Navigation from '../../../../libs/Navigation/Navigation'; import ROUTES from '../../../../ROUTES'; import * as Task from '../../../../libs/actions/Task'; -import * as Localize from '../../../../libs/Localize'; -import * as TransactionUtils from '../../../../libs/TransactionUtils'; -import * as CurrencyUtils from '../../../../libs/CurrencyUtils'; /** * Gets the HTML version of the message in an action. @@ -98,10 +95,10 @@ export default [ icon: Expensicons.Download, successTextTranslateKey: 'common.download', successIcon: Expensicons.Download, - shouldShow: (type, reportAction) => { + shouldShow: (type, reportAction, isArchivedRoom, betas, anchor, isChronosReport, reportID, isPinnedChat, isUnreadChat, isOffline) => { const isAttachment = ReportActionsUtils.isReportActionAttachment(reportAction); const messageHtml = lodashGet(reportAction, ['message', 0, 'html']); - return isAttachment && messageHtml !== CONST.ATTACHMENT_UPLOADING_MESSAGE_HTML && reportAction.reportActionID && !ReportActionsUtils.isMessageDeleted(reportAction); + return isAttachment && messageHtml !== CONST.ATTACHMENT_UPLOADING_MESSAGE_HTML && reportAction.reportActionID && !ReportActionsUtils.isMessageDeleted(reportAction) && !isOffline; }, onPress: (closePopover, {reportAction}) => { const message = _.last(lodashGet(reportAction, 'message', [{}])); @@ -203,15 +200,8 @@ export default [ const modifyExpenseMessage = ReportUtils.getModifiedExpenseMessage(reportAction); Clipboard.setString(modifyExpenseMessage); } else if (ReportActionsUtils.isMoneyRequestAction(reportAction)) { - const originalMessage = _.get(reportAction, 'originalMessage', {}); - const transaction = TransactionUtils.getTransaction(originalMessage.IOUTransactionID); - const {amount, currency, comment} = ReportUtils.getTransactionDetails(transaction); - const formattedAmount = CurrencyUtils.convertToDisplayString(amount, currency); - const displaymessage = Localize.translateLocal('iou.requestedAmount', { - formattedAmount, - comment, - }); - Clipboard.setString(displaymessage); + const displayMessage = ReportUtils.getIOUReportActionDisplayMessage(reportAction); + Clipboard.setString(displayMessage); } else if (content) { const parser = new ExpensiMark(); if (!Clipboard.canSetHtml()) { @@ -305,7 +295,7 @@ export default [ Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(childReportID)); return; } - const editAction = () => Report.saveReportActionDraft(reportID, reportAction.reportActionID, _.isEmpty(draftMessage) ? getActionText(reportAction) : ''); + const editAction = () => Report.saveReportActionDraft(reportID, reportAction, _.isEmpty(draftMessage) ? getActionText(reportAction) : ''); if (closePopover) { // Hide popover, then call editAction diff --git a/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.js b/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.js index dd0813132a8e..4f09df7330ff 100644 --- a/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.js +++ b/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.js @@ -1,96 +1,59 @@ -import React from 'react'; +import React, {forwardRef, useEffect, useState, useRef, useImperativeHandle, useCallback} from 'react'; import {Dimensions} from 'react-native'; import _ from 'underscore'; -import lodashGet from 'lodash/get'; import * as Report from '../../../../libs/actions/Report'; -import withLocalize, {withLocalizePropTypes} from '../../../../components/withLocalize'; import PopoverWithMeasuredContent from '../../../../components/PopoverWithMeasuredContent'; import BaseReportActionContextMenu from './BaseReportActionContextMenu'; import ConfirmModal from '../../../../components/ConfirmModal'; -import CONST from '../../../../CONST'; import * as ReportActionsUtils from '../../../../libs/ReportActionsUtils'; import * as IOU from '../../../../libs/actions/IOU'; +import useLocalize from '../../../../hooks/useLocalize'; -const propTypes = { - ...withLocalizePropTypes, -}; - -class PopoverReportActionContextMenu extends React.Component { - constructor(props) { - super(props); - - this.state = { - reportID: '0', - reportActionID: '0', - originalReportID: '0', - reportAction: {}, - selection: '', - reportActionDraftMessage: '', - isPopoverVisible: false, - isDeleteCommentConfirmModalVisible: false, - shouldSetModalVisibilityForDeleteConfirmation: true, - cursorRelativePosition: { - horizontal: 0, - vertical: 0, - }, - - // The horizontal and vertical position (relative to the screen) where the popover will display. - popoverAnchorPosition: { - horizontal: 0, - vertical: 0, - }, - isArchivedRoom: false, - isChronosReport: false, - isPinnedChat: false, - isUnreadChat: false, - }; - this.onPopoverShow = () => {}; - this.onPopoverHide = () => {}; - this.onPopoverHideActionCallback = () => {}; - this.contextMenuAnchor = undefined; - this.showContextMenu = this.showContextMenu.bind(this); - this.hideContextMenu = this.hideContextMenu.bind(this); - this.measureContextMenuAnchorPosition = this.measureContextMenuAnchorPosition.bind(this); - this.confirmDeleteAndHideModal = this.confirmDeleteAndHideModal.bind(this); - this.hideDeleteModal = this.hideDeleteModal.bind(this); - this.showDeleteModal = this.showDeleteModal.bind(this); - this.runAndResetOnPopoverShow = this.runAndResetOnPopoverShow.bind(this); - this.runAndResetOnPopoverHide = this.runAndResetOnPopoverHide.bind(this); - this.getContextMenuMeasuredLocation = this.getContextMenuMeasuredLocation.bind(this); - this.isActiveReportAction = this.isActiveReportAction.bind(this); - this.clearActiveReportAction = this.clearActiveReportAction.bind(this); - - this.dimensionsEventListener = null; - - this.contentRef = React.createRef(); - this.setContentRef = (ref) => { - this.contentRef.current = ref; - }; - this.setContentRef = this.setContentRef.bind(this); - this.anchorRef = React.createRef(); - } - - componentDidMount() { - this.dimensionsEventListener = Dimensions.addEventListener('change', this.measureContextMenuAnchorPosition); - } - - shouldComponentUpdate(nextProps, nextState) { - const previousLocale = lodashGet(this.props, 'preferredLocale', CONST.LOCALES.DEFAULT); - const nextLocale = lodashGet(nextProps, 'preferredLocale', CONST.LOCALES.DEFAULT); - return ( - this.state.isPopoverVisible !== nextState.isPopoverVisible || - this.state.popoverAnchorPosition !== nextState.popoverAnchorPosition || - this.state.isDeleteCommentConfirmModalVisible !== nextState.isDeleteCommentConfirmModalVisible || - previousLocale !== nextLocale - ); - } - - componentWillUnmount() { - if (!this.dimensionsEventListener) { - return; - } - this.dimensionsEventListener.remove(); - } +function PopoverReportActionContextMenu(_props, ref) { + const {translate} = useLocalize(); + const reportIDRef = useRef('0'); + const typeRef = useRef(''); + const reportActionRef = useRef({}); + const reportActionIDRef = useRef('0'); + const originalReportIDRef = useRef('0'); + const selectionRef = useRef(''); + const reportActionDraftMessageRef = useRef(''); + + const cursorRelativePosition = useRef({ + horizontal: 0, + vertical: 0, + }); + + // The horizontal and vertical position (relative to the screen) where the popover will display. + const popoverAnchorPosition = useRef({ + horizontal: 0, + vertical: 0, + }); + + const [instanceID, setInstanceID] = useState(''); + + const [isPopoverVisible, setIsPopoverVisible] = useState(false); + const [isDeleteCommentConfirmModalVisible, setIsDeleteCommentConfirmModalVisible] = useState(false); + const [shouldSetModalVisibilityForDeleteConfirmation, setShouldSetModalVisibilityForDeleteConfirmation] = useState(true); + + const [isRoomArchived, setIsRoomArchived] = useState(false); + const [isChronosReportEnabled, setIsChronosReportEnabled] = useState(false); + const [isChatPinned, setIsChatPinned] = useState(false); + const [hasUnreadMessages, setHasUnreadMessages] = useState(false); + + const contentRef = useRef(null); + const anchorRef = useRef(null); + const dimensionsEventListener = useRef(null); + const contextMenuAnchorRef = useRef(null); + const contextMenuTargetNode = useRef(null); + + const onPopoverShow = useRef(() => {}); + const onPopoverHide = useRef(() => {}); + const onCancelDeleteModal = useRef(() => {}); + const onComfirmDeleteModal = useRef(() => {}); + + const onPopoverHideActionCallback = useRef(() => {}); + const callbackWhenDeleteModalHide = useRef(() => {}); /** * Get the Context menu anchor position @@ -98,15 +61,48 @@ class PopoverReportActionContextMenu extends React.Component { * * @returns {Promise} */ - getContextMenuMeasuredLocation() { - return new Promise((resolve) => { - if (this.contextMenuAnchor) { - (this.contextMenuAnchor.current || this.contextMenuAnchor).measureInWindow((x, y) => resolve({x, y})); - } else { - resolve({x: 0, y: 0}); + const getContextMenuMeasuredLocation = useCallback( + () => + new Promise((resolve) => { + if (contextMenuAnchorRef.current && _.isFunction(contextMenuAnchorRef.current.measureInWindow)) { + contextMenuAnchorRef.current.measureInWindow((x, y) => resolve({x, y})); + } else { + resolve({x: 0, y: 0}); + } + }), + [], + ); + + /** + * This gets called on Dimensions change to find the anchor coordinates for the action context menu. + */ + const measureContextMenuAnchorPosition = useCallback(() => { + if (!isPopoverVisible) { + return; + } + + getContextMenuMeasuredLocation().then(({x, y}) => { + if (!x || !y) { + return; } + + popoverAnchorPosition.current = { + horizontal: cursorRelativePosition.horizontal + x, + vertical: cursorRelativePosition.vertical + y, + }; }); - } + }, [isPopoverVisible, getContextMenuMeasuredLocation]); + + useEffect(() => { + dimensionsEventListener.current = Dimensions.addEventListener('change', measureContextMenuAnchorPosition); + + return () => { + if (!dimensionsEventListener.current) { + return; + } + dimensionsEventListener.current.remove(); + }; + }, [measureContextMenuAnchorPosition]); /** * Whether Context Menu is active for the Report Action. @@ -114,13 +110,12 @@ class PopoverReportActionContextMenu extends React.Component { * @param {Number|String} actionID * @return {Boolean} */ - isActiveReportAction(actionID) { - return Boolean(actionID) && (this.state.reportActionID === actionID || this.state.reportAction.reportActionID === actionID); - } + const isActiveReportAction = (actionID) => Boolean(actionID) && (reportActionIDRef.current === actionID || reportActionRef.current.reportActionID === actionID); - clearActiveReportAction() { - this.setState({reportID: '0', reportAction: {}}); - } + const clearActiveReportAction = () => { + reportActionIDRef.current = '0'; + reportActionRef.current = {}; + }; /** * Show the ReportActionContextMenu modal popover. @@ -140,7 +135,7 @@ class PopoverReportActionContextMenu extends React.Component { * @param {Boolean} isPinnedChat - Flag to check if the chat is pinned in the LHN. Used for the Pin/Unpin action * @param {Boolean} isUnreadChat - Flag to check if the chat is unread in the LHN. Used for the Mark as Read/Unread action */ - showContextMenu( + const showContextMenu = ( type, event, selection, @@ -155,130 +150,105 @@ class PopoverReportActionContextMenu extends React.Component { isChronosReport = false, isPinnedChat = false, isUnreadChat = false, - ) { + ) => { const nativeEvent = event.nativeEvent || {}; - this.contextMenuAnchor = contextMenuAnchor; - this.contextMenuTargetNode = nativeEvent.target; - - // Singleton behaviour of ContextMenu creates race conditions when user requests multiple contextMenus. - // But it is possible that every new request registers new callbacks thus instanceID is used to corelate those callbacks - this.instanceID = Math.random().toString(36).substr(2, 5); - - this.onPopoverShow = onShow; - this.onPopoverHide = onHide; - - this.getContextMenuMeasuredLocation().then(({x, y}) => { - this.setState({ - cursorRelativePosition: { - horizontal: nativeEvent.pageX - x, - vertical: nativeEvent.pageY - y, - }, - popoverAnchorPosition: { - horizontal: nativeEvent.pageX, - vertical: nativeEvent.pageY, - }, - type, - reportID, - reportActionID, - originalReportID, - selection, - isPopoverVisible: true, - reportActionDraftMessage: draftMessage, - isArchivedRoom, - isChronosReport, - isPinnedChat, - isUnreadChat, - }); - }); - } + contextMenuAnchorRef.current = contextMenuAnchor; + contextMenuTargetNode.current = nativeEvent.target; - /** - * This gets called on Dimensions change to find the anchor coordinates for the action context menu. - */ - measureContextMenuAnchorPosition() { - if (!this.state.isPopoverVisible) { - return; - } - this.getContextMenuMeasuredLocation().then(({x, y}) => { - if (!x || !y) { - return; - } - this.setState((prev) => ({ - popoverAnchorPosition: { - horizontal: prev.cursorRelativePosition.horizontal + x, - vertical: prev.cursorRelativePosition.vertical + y, - }, - })); + setInstanceID(Math.random().toString(36).substr(2, 5)); + + onPopoverShow.current = onShow; + onPopoverHide.current = onHide; + + getContextMenuMeasuredLocation().then(({x, y}) => { + popoverAnchorPosition.current = { + horizontal: nativeEvent.pageX - x, + vertical: nativeEvent.pageY - y, + }; + + popoverAnchorPosition.current = { + horizontal: nativeEvent.pageX, + vertical: nativeEvent.pageY, + }; + typeRef.current = type; + reportIDRef.current = reportID; + reportActionIDRef.current = reportActionID; + originalReportIDRef.current = originalReportID; + selectionRef.current = selection; + setIsPopoverVisible(true); + reportActionDraftMessageRef.current = draftMessage; + setIsRoomArchived(isArchivedRoom); + setIsChronosReportEnabled(isChronosReport); + setIsChatPinned(isPinnedChat); + setHasUnreadMessages(isUnreadChat); }); - } + }; /** * After Popover shows, call the registered onPopoverShow callback and reset it */ - runAndResetOnPopoverShow() { - this.onPopoverShow(); + const runAndResetOnPopoverShow = () => { + onPopoverShow.current(); // After we have called the action, reset it. - this.onPopoverShow = () => {}; - } + onPopoverShow.current = () => {}; + }; + + /** + * Run the callback and return a noop function to reset it + * @param {Function} callback + * @returns {Function} + */ + const runAndResetCallback = (callback) => { + callback(); + return () => {}; + }; /** * After Popover hides, call the registered onPopoverHide & onPopoverHideActionCallback callback and reset it */ - runAndResetOnPopoverHide() { - this.setState({reportID: '0', reportActionID: '0', originalReportID: '0'}, () => { - this.onPopoverHide = this.runAndResetCallback(this.onPopoverHide); - this.onPopoverHideActionCallback = this.runAndResetCallback(this.onPopoverHideActionCallback); - }); - } + const runAndResetOnPopoverHide = () => { + reportIDRef.current = '0'; + reportActionIDRef.current = '0'; + originalReportIDRef.current = '0'; + + onPopoverHide.current = runAndResetCallback(onPopoverHide.current); + onPopoverHideActionCallback.current = runAndResetCallback(onPopoverHideActionCallback.current); + }; /** * Hide the ReportActionContextMenu modal popover. * @param {Function} onHideActionCallback Callback to be called after popover is completely hidden */ - hideContextMenu(onHideActionCallback) { + const hideContextMenu = (onHideActionCallback) => { if (_.isFunction(onHideActionCallback)) { - this.onPopoverHideActionCallback = onHideActionCallback; + onPopoverHideActionCallback.current = onHideActionCallback; } - this.setState({ - selection: '', - reportActionDraftMessage: '', - isPopoverVisible: false, - }); - } - /** - * Run the callback and return a noop function to reset it - * @param {Function} callback - * @returns {Function} - */ - runAndResetCallback(callback) { - callback(); - return () => {}; - } - - confirmDeleteAndHideModal() { - this.callbackWhenDeleteModalHide = () => (this.onComfirmDeleteModal = this.runAndResetCallback(this.onComfirmDeleteModal)); + selectionRef.current = ''; + reportActionDraftMessageRef.current = ''; + setIsPopoverVisible(false); + }; - if (ReportActionsUtils.isMoneyRequestAction(this.state.reportAction)) { - IOU.deleteMoneyRequest(this.state.reportAction.originalMessage.IOUTransactionID, this.state.reportAction); + const confirmDeleteAndHideModal = useCallback(() => { + callbackWhenDeleteModalHide.current = () => (onComfirmDeleteModal.current = runAndResetCallback(onComfirmDeleteModal.current)); + if (ReportActionsUtils.isMoneyRequestAction(reportActionRef.current)) { + IOU.deleteMoneyRequest(reportActionRef.current.originalMessage.IOUTransactionID, reportActionRef.current); } else { - Report.deleteReportComment(this.state.reportID, this.state.reportAction); + Report.deleteReportComment(reportIDRef.current, reportActionRef.current); } - this.setState({isDeleteCommentConfirmModalVisible: false}); - } - - hideDeleteModal() { - this.callbackWhenDeleteModalHide = () => (this.onCancelDeleteModal = this.runAndResetCallback(this.onCancelDeleteModal)); - this.setState({ - isDeleteCommentConfirmModalVisible: false, - shouldSetModalVisibilityForDeleteConfirmation: true, - isArchivedRoom: false, - isChronosReport: false, - isPinnedChat: false, - isUnreadChat: false, - }); - } + setIsDeleteCommentConfirmModalVisible(false); + }, [reportActionRef]); + + const hideDeleteModal = () => { + callbackWhenDeleteModalHide.current = () => (onCancelDeleteModal.current = runAndResetCallback(onCancelDeleteModal.current)); + setIsDeleteCommentConfirmModalVisible(false); + setShouldSetModalVisibilityForDeleteConfirmation(true); + setIsRoomArchived(false); + setIsChronosReportEnabled(false); + setIsChatPinned(false); + setHasUnreadMessages(false); + }; /** * Opens the Confirm delete action modal @@ -288,67 +258,82 @@ class PopoverReportActionContextMenu extends React.Component { * @param {Function} [onConfirm] * @param {Function} [onCancel] */ - showDeleteModal(reportID, reportAction, shouldSetModalVisibility = true, onConfirm = () => {}, onCancel = () => {}) { - this.onCancelDeleteModal = onCancel; - this.onComfirmDeleteModal = onConfirm; - this.setState({ - reportID, - reportAction, - shouldSetModalVisibilityForDeleteConfirmation: shouldSetModalVisibility, - isDeleteCommentConfirmModalVisible: true, - }); - } - - render() { - return ( - <> - - - - {}, onCancel = () => {}) => { + onCancelDeleteModal.current = onCancel; + onComfirmDeleteModal.current = onConfirm; + + reportIDRef.current = reportID; + reportActionRef.current = reportAction; + + setShouldSetModalVisibilityForDeleteConfirmation(shouldSetModalVisibility); + setIsDeleteCommentConfirmModalVisible(true); + }; + + useImperativeHandle(ref, () => ({ + showContextMenu, + hideContextMenu, + showDeleteModal, + hideDeleteModal, + isActiveReportAction, + instanceID, + runAndResetOnPopoverHide, + clearActiveReportAction, + })); + + const reportAction = reportActionRef.current; + + return ( + <> + + - - ); - } + + { + reportIDRef.current = '0'; + reportActionRef.current = {}; + callbackWhenDeleteModalHide.current(); + }} + prompt={translate('reportActionContextMenu.deleteConfirmation', {action: reportAction})} + confirmText={translate('common.delete')} + cancelText={translate('common.cancel')} + danger + /> + + ); } -PopoverReportActionContextMenu.propTypes = propTypes; +PopoverReportActionContextMenu.displayName = 'PopoverReportActionContextMenu'; -export default withLocalize(PopoverReportActionContextMenu); +export default forwardRef(PopoverReportActionContextMenu); diff --git a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js index ccf7a0a51518..d04983dc2f75 100644 --- a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js +++ b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js @@ -33,6 +33,7 @@ import compose from '../../../../libs/compose'; import withKeyboardState from '../../../../components/withKeyboardState'; import {propTypes, defaultProps} from './composerWithSuggestionsProps'; import focusWithDelay from '../../../../libs/focusWithDelay'; +import useDebounce from '../../../../hooks/useDebounce'; const {RNTextInputReset} = NativeModules; @@ -91,7 +92,6 @@ function ComposerWithSuggestions({ setIsFullComposerAvailable, setIsCommentEmpty, submitForm, - shouldShowReportRecipientLocalTime, shouldShowComposeInput, measureParentContainer, // Refs @@ -126,6 +126,9 @@ function ComposerWithSuggestions({ const textInputRef = useRef(null); const insertedEmojisRef = useRef([]); + // A flag to indicate whether the onScroll callback is likely triggered by a layout change (caused by text change) or not + const isScrollLikelyLayoutTriggered = useRef(false); + /** * Update frequently used emojis list. We debounce this method in the constructor so that UpdateFrequentlyUsedEmojis * API is not called too often. @@ -135,6 +138,23 @@ function ComposerWithSuggestions({ insertedEmojisRef.current = []; }, []); + /** + * Reset isScrollLikelyLayoutTriggered to false. + * + * The function is debounced with a handpicked wait time to address 2 issues: + * 1. There is a slight delay between onChangeText and onScroll + * 2. Layout change will trigger onScroll multiple times + */ + const debouncedLowerIsScrollLikelyLayoutTriggered = useDebounce( + useCallback(() => (isScrollLikelyLayoutTriggered.current = false), []), + 500, + ); + + const raiseIsScrollLikelyLayoutTriggered = useCallback(() => { + isScrollLikelyLayoutTriggered.current = true; + debouncedLowerIsScrollLikelyLayoutTriggered(); + }, [debouncedLowerIsScrollLikelyLayoutTriggered]); + const onInsertedEmoji = useCallback( (emojiObject) => { insertedEmojisRef.current = [...insertedEmojisRef.current, emojiObject]; @@ -175,6 +195,7 @@ function ComposerWithSuggestions({ */ const updateComment = useCallback( (commentValue, shouldDebounceSaveComment) => { + raiseIsScrollLikelyLayoutTriggered(); const {text: newComment, emojis} = EmojiUtils.replaceAndExtractEmojis(commentValue, preferredSkinTone, preferredLocale); if (!_.isEmpty(emojis)) { @@ -190,7 +211,7 @@ function ComposerWithSuggestions({ suggestionsRef.current.resetSuggestions(); } - const remainder = ComposerUtils.getCommonSuffixLength(commentRef.current, newComment); + const remainder = ComposerUtils.getCommonSuffixLength(commentValue, newComment); setSelection({ start: newComment.length - remainder, end: newComment.length - remainder, @@ -217,7 +238,7 @@ function ComposerWithSuggestions({ debouncedBroadcastUserIsTyping(reportID); } }, - [debouncedUpdateFrequentlyUsedEmojis, preferredLocale, preferredSkinTone, reportID, setIsCommentEmpty, suggestionsRef], + [debouncedUpdateFrequentlyUsedEmojis, preferredLocale, preferredSkinTone, reportID, setIsCommentEmpty, suggestionsRef, raiseIsScrollLikelyLayoutTriggered], ); /** @@ -306,7 +327,7 @@ function ComposerWithSuggestions({ (action) => ReportUtils.canEditReportAction(action) && !ReportActionsUtils.isMoneyRequestAction(action), ); if (lastReportAction) { - Report.saveReportActionDraft(reportID, lastReportAction.reportActionID, _.last(lastReportAction.message).html); + Report.saveReportActionDraft(reportID, lastReportAction, _.last(lastReportAction.message).html); } } }, @@ -324,8 +345,8 @@ function ComposerWithSuggestions({ [suggestionsRef], ); - const updateShouldShowSuggestionMenuToFalse = useCallback(() => { - if (!suggestionsRef.current) { + const hideSuggestionMenu = useCallback(() => { + if (!suggestionsRef.current || isScrollLikelyLayoutTriggered.current) { return; } suggestionsRef.current.updateShouldShowSuggestionMenuToFalse(false); @@ -504,7 +525,7 @@ function ComposerWithSuggestions({ } setComposerHeight(composerLayoutHeight); }} - onScroll={updateShouldShowSuggestionMenuToFalse} + onScroll={hideSuggestionMenu} />
      @@ -513,7 +534,6 @@ function ComposerWithSuggestions({ isComposerFullSize={isComposerFullSize} updateComment={updateComment} composerHeight={composerHeight} - shouldShowReportRecipientLocalTime={shouldShowReportRecipientLocalTime} onInsertedEmoji={onInsertedEmoji} measureParentContainer={measureParentContainer} // Input diff --git a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js index 6ef0a9867ece..46153bda15e6 100644 --- a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js +++ b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js @@ -5,6 +5,7 @@ import _ from 'underscore'; import lodashGet from 'lodash/get'; import {withOnyx} from 'react-native-onyx'; import {useAnimatedRef} from 'react-native-reanimated'; +import {PortalHost} from '@gorhom/portal'; import styles from '../../../../styles/styles'; import ONYXKEYS from '../../../../ONYXKEYS'; import * as Report from '../../../../libs/actions/Report'; @@ -318,6 +319,7 @@ function ReportActionCompose({ ref={containerRef} style={[shouldShowReportRecipientLocalTime && !lodashGet(network, 'isOffline') && styles.chatItemComposeWithFirstRow, isComposerFullSize && styles.chatItemFullComposeRow]} > + `${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${reportID}`, + initialValue: '', }, })(SilentCommentUpdater); diff --git a/src/pages/home/report/ReportActionCompose/SuggestionEmoji.js b/src/pages/home/report/ReportActionCompose/SuggestionEmoji.js index a760627e53cc..910a338c83b6 100644 --- a/src/pages/home/report/ReportActionCompose/SuggestionEmoji.js +++ b/src/pages/home/report/ReportActionCompose/SuggestionEmoji.js @@ -58,7 +58,6 @@ function SuggestionEmoji({ setSelection, updateComment, isComposerFullSize, - shouldShowReportRecipientLocalTime, isAutoSuggestionPickerLarge, forwardedRef, resetKeyboardInput, @@ -235,7 +234,6 @@ function SuggestionEmoji({ isComposerFullSize={isComposerFullSize} preferredSkinToneIndex={preferredSkinTone} isEmojiPickerLarge={isAutoSuggestionPickerLarge} - shouldIncludeReportRecipientLocalTimeHeight={shouldShowReportRecipientLocalTime} measureParentContainer={measureParentContainer} /> ); diff --git a/src/pages/home/report/ReportActionCompose/SuggestionMention.js b/src/pages/home/report/ReportActionCompose/SuggestionMention.js index a76025b67b1e..6c08b68cdc78 100644 --- a/src/pages/home/report/ReportActionCompose/SuggestionMention.js +++ b/src/pages/home/report/ReportActionCompose/SuggestionMention.js @@ -51,7 +51,6 @@ function SuggestionMention({ personalDetails, updateComment, composerHeight, - shouldShowReportRecipientLocalTime, forwardedRef, isAutoSuggestionPickerLarge, measureParentContainer, @@ -169,6 +168,7 @@ function SuggestionMention({ name: detail.login, source: UserUtils.getAvatar(detail.avatar, detail.accountID), type: 'avatar', + fallbackIcon: detail.fallbackIcon, }, ], }); @@ -199,7 +199,7 @@ function SuggestionMention({ } const leftString = value.substring(0, indexOfLastNonWhitespaceCharAfterTheCursor); - const words = leftString.split(CONST.REGEX.SPECIAL_CHAR_OR_EMOJI); + const words = leftString.split(CONST.REGEX.SPACE_OR_EMOJI); const lastWord = _.last(words); let atSignIndex; @@ -284,7 +284,6 @@ function SuggestionMention({ isComposerFullSize={isComposerFullSize} isMentionPickerLarge={isAutoSuggestionPickerLarge} composerHeight={composerHeight} - shouldIncludeReportRecipientLocalTimeHeight={shouldShowReportRecipientLocalTime} measureParentContainer={measureParentContainer} /> ); diff --git a/src/pages/home/report/ReportActionCompose/Suggestions.js b/src/pages/home/report/ReportActionCompose/Suggestions.js index 60cb9de4ccfb..a00bd342b17d 100644 --- a/src/pages/home/report/ReportActionCompose/Suggestions.js +++ b/src/pages/home/report/ReportActionCompose/Suggestions.js @@ -36,7 +36,6 @@ function Suggestions({ setSelection, updateComment, composerHeight, - shouldShowReportRecipientLocalTime, forwardedRef, onInsertedEmoji, resetKeyboardInput, @@ -105,7 +104,6 @@ function Suggestions({ isComposerFullSize, updateComment, composerHeight, - shouldShowReportRecipientLocalTime, isAutoSuggestionPickerLarge, measureParentContainer, }; diff --git a/src/pages/home/report/ReportActionCompose/composerWithSuggestionsProps.js b/src/pages/home/report/ReportActionCompose/composerWithSuggestionsProps.js index b8d9f0b6d816..0c8f36114c44 100644 --- a/src/pages/home/report/ReportActionCompose/composerWithSuggestionsProps.js +++ b/src/pages/home/report/ReportActionCompose/composerWithSuggestionsProps.js @@ -74,9 +74,6 @@ const propTypes = { /** A method to call when the form is submitted */ submitForm: PropTypes.func.isRequired, - /** Whether the recipient local time is shown or not */ - shouldShowReportRecipientLocalTime: PropTypes.bool.isRequired, - /** Whether the compose input is shown or not */ shouldShowComposeInput: PropTypes.bool.isRequired, diff --git a/src/pages/home/report/ReportActionCompose/suggestionProps.js b/src/pages/home/report/ReportActionCompose/suggestionProps.js index 12447929b980..815a1c5619f5 100644 --- a/src/pages/home/report/ReportActionCompose/suggestionProps.js +++ b/src/pages/home/report/ReportActionCompose/suggestionProps.js @@ -22,9 +22,6 @@ const baseProps = { /** Callback to update the comment draft */ updateComment: PropTypes.func.isRequired, - /** Flag whether we need to consider the participants */ - shouldShowReportRecipientLocalTime: PropTypes.bool.isRequired, - /** Meaures the parent container's position and dimensions. */ measureParentContainer: PropTypes.func.isRequired, }; diff --git a/src/pages/home/report/ReportActionItem.js b/src/pages/home/report/ReportActionItem.js index c3d52ff377a8..028af06d1349 100644 --- a/src/pages/home/report/ReportActionItem.js +++ b/src/pages/home/report/ReportActionItem.js @@ -64,8 +64,8 @@ import {hideContextMenu} from './ContextMenu/ReportActionContextMenu'; import * as PersonalDetailsUtils from '../../../libs/PersonalDetailsUtils'; import * as store from '../../../libs/actions/ReimbursementAccount/store'; import * as BankAccounts from '../../../libs/actions/BankAccounts'; +import {ReactionListContext} from '../ReportScreenContext'; import usePrevious from '../../../hooks/usePrevious'; -import ReportScreenContext from '../ReportScreenContext'; import Permissions from '../../../libs/Permissions'; import themeColors from '../../../styles/themes/default'; import ReportActionItemBasicMessage from './ReportActionItemBasicMessage'; @@ -133,7 +133,7 @@ function ReportActionItem(props) { const [isContextMenuActive, setIsContextMenuActive] = useState(ReportActionContextMenu.isActiveReportAction(props.action.reportActionID)); const [isHidden, setIsHidden] = useState(false); const [moderationDecision, setModerationDecision] = useState(CONST.MODERATION.MODERATOR_DECISION_APPROVED); - const {reactionListRef} = useContext(ReportScreenContext); + const reactionListRef = useContext(ReactionListContext); const {updateHiddenAttachments} = useContext(ReportAttachmentsContext); const textInputRef = useRef(); const popoverAnchorRef = useRef(); @@ -518,12 +518,10 @@ function ReportActionItem(props) { checkIfContextMenuActive: toggleContextMenuFromActiveReportAction, }} > - - - + ); } @@ -545,12 +543,10 @@ function ReportActionItem(props) { } return ( - - - + ); } if (ReportUtils.isExpenseReport(props.report) || ReportUtils.isIOUReport(props.report)) { @@ -679,13 +675,15 @@ export default compose( withReportActionsDrafts({ propName: 'draftMessage', transformValue: (drafts, props) => { - const draftKey = `${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${props.report.reportID}_${props.action.reportActionID}`; + const originalReportID = ReportUtils.getOriginalReportID(props.report.reportID, props.action); + const draftKey = `${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${originalReportID}_${props.action.reportActionID}`; return lodashGet(drafts, draftKey, ''); }, }), withOnyx({ preferredSkinTone: { key: ONYXKEYS.PREFERRED_EMOJI_SKIN_TONE, + initialValue: CONST.EMOJI_DEFAULT_SKIN_TONE, }, iouReport: { key: ({action}) => { @@ -696,6 +694,7 @@ export default compose( }, emojiReactions: { key: ({action}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS_REACTIONS}${action.reportActionID}`, + initialValue: {}, }, }), )( @@ -710,6 +709,7 @@ export default compose( _.isEqual(prevProps.emojiReactions, nextProps.emojiReactions) && _.isEqual(prevProps.action, nextProps.action) && _.isEqual(prevProps.report.pendingFields, nextProps.report.pendingFields) && + _.isEqual(prevProps.report.isDeletedParentAction, nextProps.report.isDeletedParentAction) && _.isEqual(prevProps.report.errorFields, nextProps.report.errorFields) && lodashGet(prevProps.report, 'statusNum') === lodashGet(nextProps.report, 'statusNum') && lodashGet(prevProps.report, 'stateNum') === lodashGet(nextProps.report, 'stateNum') && diff --git a/src/pages/home/report/ReportActionItemMessageEdit.js b/src/pages/home/report/ReportActionItemMessageEdit.js index 6ce826a2a34c..3ceaf69b52f5 100644 --- a/src/pages/home/report/ReportActionItemMessageEdit.js +++ b/src/pages/home/report/ReportActionItemMessageEdit.js @@ -7,13 +7,11 @@ import ExpensiMark from 'expensify-common/lib/ExpensiMark'; import Str from 'expensify-common/lib/str'; import reportActionPropTypes from './reportActionPropTypes'; import styles from '../../../styles/styles'; -import compose from '../../../libs/compose'; import themeColors from '../../../styles/themes/default'; import * as StyleUtils from '../../../styles/StyleUtils'; import containerComposeStyles from '../../../styles/containerComposeStyles'; import Composer from '../../../components/Composer'; import * as Report from '../../../libs/actions/Report'; -import {withReportActionsDrafts} from '../../../components/OnyxProvider'; import setShouldShowComposeInputKeyboardAware from '../../../libs/setShouldShowComposeInputKeyboardAware'; import ReportActionComposeFocusManager from '../../../libs/ReportActionComposeFocusManager'; import EmojiPickerButton from '../../../components/EmojiPicker/EmojiPickerButton'; @@ -22,6 +20,7 @@ import * as Expensicons from '../../../components/Icon/Expensicons'; import Tooltip from '../../../components/Tooltip'; import * as ReportActionContextMenu from './ContextMenu/ReportActionContextMenu'; import * as ReportUtils from '../../../libs/ReportUtils'; +import * as ReportActionsUtils from '../../../libs/ReportActionsUtils'; import * as EmojiUtils from '../../../libs/EmojiUtils'; import reportPropTypes from '../../reportPropTypes'; import ExceededCommentLength from '../../../components/ExceededCommentLength'; @@ -31,14 +30,12 @@ import * as ComposerUtils from '../../../libs/ComposerUtils'; import * as User from '../../../libs/actions/User'; import PressableWithFeedback from '../../../components/Pressable/PressableWithFeedback'; import getButtonState from '../../../libs/getButtonState'; -import withLocalize, {withLocalizePropTypes} from '../../../components/withLocalize'; import useLocalize from '../../../hooks/useLocalize'; import useKeyboardState from '../../../hooks/useKeyboardState'; import useWindowDimensions from '../../../hooks/useWindowDimensions'; import useReportScrollManager from '../../../hooks/useReportScrollManager'; import * as EmojiPickerAction from '../../../libs/actions/EmojiPickerAction'; import focusWithDelay from '../../../libs/focusWithDelay'; -import ONYXKEYS from '../../../ONYXKEYS'; import * as Browser from '../../../libs/Browser'; const propTypes = { @@ -64,14 +61,8 @@ const propTypes = { /** Whether or not the emoji picker is disabled */ shouldDisableEmojiPicker: PropTypes.bool, - /** Draft message - if this is set the comment is in 'edit' mode */ - // eslint-disable-next-line react/forbid-prop-types - drafts: PropTypes.object, - /** Stores user's preferred skin tone */ preferredSkinTone: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), - - ...withLocalizePropTypes, }; const defaultProps = { @@ -79,7 +70,6 @@ const defaultProps = { report: {}, shouldDisableEmojiPicker: false, preferredSkinTone: CONST.EMOJI_DEFAULT_SKIN_TONE, - drafts: {}, }; // native ids @@ -90,7 +80,7 @@ const isMobileSafari = Browser.isMobileSafari(); function ReportActionItemMessageEdit(props) { const reportScrollManager = useReportScrollManager(); - const {translate} = useLocalize(); + const {translate, preferredLocale} = useLocalize(); const {isKeyboardShown} = useKeyboardState(); const {isSmallScreenWidth} = useWindowDimensions(); @@ -122,6 +112,13 @@ function ReportActionItemMessageEdit(props) { const isFocusedRef = useRef(false); const insertedEmojis = useRef([]); + useEffect(() => { + if (ReportActionsUtils.isDeletedAction(props.action) || props.draftMessage === props.action.message[0].html) { + return; + } + setDraft(Str.htmlDecode(props.draftMessage)); + }, [props.draftMessage, props.action]); + useEffect(() => { // required for keeping last state of isFocused variable isFocusedRef.current = isFocused; @@ -175,9 +172,9 @@ function ReportActionItemMessageEdit(props) { const debouncedSaveDraft = useMemo( () => _.debounce((newDraft) => { - Report.saveReportActionDraft(props.reportID, props.action.reportActionID, newDraft); + Report.saveReportActionDraft(props.reportID, props.action, newDraft); }, 1000), - [props.reportID, props.action.reportActionID], + [props.reportID, props.action], ); /** @@ -200,7 +197,7 @@ function ReportActionItemMessageEdit(props) { */ const updateDraft = useCallback( (newDraftInput) => { - const {text: newDraft, emojis} = EmojiUtils.replaceAndExtractEmojis(newDraftInput, props.preferredSkinTone, props.preferredLocale); + const {text: newDraft, emojis} = EmojiUtils.replaceAndExtractEmojis(newDraftInput, props.preferredSkinTone, preferredLocale); if (!_.isEmpty(emojis)) { insertedEmojis.current = [...insertedEmojis.current, ...emojis]; @@ -228,7 +225,7 @@ function ReportActionItemMessageEdit(props) { debouncedSaveDraft(props.action.message[0].html); } }, - [props.action.message, debouncedSaveDraft, debouncedUpdateFrequentlyUsedEmojis, props.preferredSkinTone, props.preferredLocale], + [props.action.message, debouncedSaveDraft, debouncedUpdateFrequentlyUsedEmojis, props.preferredSkinTone, preferredLocale], ); /** @@ -236,7 +233,7 @@ function ReportActionItemMessageEdit(props) { */ const deleteDraft = useCallback(() => { debouncedSaveDraft.cancel(); - Report.saveReportActionDraft(props.reportID, props.action.reportActionID, ''); + Report.saveReportActionDraft(props.reportID, props.action, ''); if (isActive()) { ReportActionComposeFocusManager.clear(); @@ -250,7 +247,7 @@ function ReportActionItemMessageEdit(props) { keyboardDidHideListener.remove(); }); } - }, [props.action.reportActionID, debouncedSaveDraft, props.index, props.reportID, reportScrollManager, isActive]); + }, [props.action, debouncedSaveDraft, props.index, props.reportID, reportScrollManager, isActive]); /** * Save the draft of the comment to be the new comment message. This will take the comment out of "edit mode" with @@ -268,21 +265,6 @@ function ReportActionItemMessageEdit(props) { const trimmedNewDraft = draft.trim(); - const report = ReportUtils.getReport(props.reportID); - - // Updates in child message should cause the parent draft message to change - if (report.parentReportActionID && lodashGet(props.action, 'childType', '') === CONST.REPORT.TYPE.CHAT) { - if (lodashGet(props.drafts, [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${report.parentReportID}_${props.action.reportActionID}`], undefined)) { - Report.saveReportActionDraft(report.parentReportID, props.action.reportActionID, trimmedNewDraft); - } - } - // Updates in the parent message should cause the child draft message to change - if (props.action.childReportID) { - if (lodashGet(props.drafts, [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${props.action.childReportID}_${props.action.reportActionID}`], undefined)) { - Report.saveReportActionDraft(props.action.childReportID, props.action.reportActionID, trimmedNewDraft); - } - } - // When user tries to save the empty message, it will delete it. Prompt the user to confirm deleting. if (!trimmedNewDraft) { textInputRef.current.blur(); @@ -291,7 +273,7 @@ function ReportActionItemMessageEdit(props) { } Report.editReportComment(props.reportID, props.action, trimmedNewDraft); deleteDraft(); - }, [props.action, debouncedSaveDraft, deleteDraft, draft, props.reportID, props.drafts]); + }, [props.action, debouncedSaveDraft, deleteDraft, draft, props.reportID]); /** * @param {String} emoji @@ -453,17 +435,10 @@ ReportActionItemMessageEdit.propTypes = propTypes; ReportActionItemMessageEdit.defaultProps = defaultProps; ReportActionItemMessageEdit.displayName = 'ReportActionItemMessageEdit'; -export default compose( - withLocalize, - withReportActionsDrafts({ - propName: 'drafts', - }), -)( - React.forwardRef((props, ref) => ( - - )), -); +export default React.forwardRef((props, ref) => ( + +)); diff --git a/src/pages/home/report/ReportActionItemSingle.js b/src/pages/home/report/ReportActionItemSingle.js index 97dd5e37482e..ca0467143e98 100644 --- a/src/pages/home/report/ReportActionItemSingle.js +++ b/src/pages/home/report/ReportActionItemSingle.js @@ -90,7 +90,7 @@ const showWorkspaceDetails = (reportID) => { function ReportActionItemSingle(props) { const actorAccountID = props.action.actorAccountID; let {displayName} = props.personalDetailsList[actorAccountID] || {}; - const {avatar, login, pendingFields, status} = props.personalDetailsList[actorAccountID] || {}; + const {avatar, login, pendingFields, status, fallbackIcon} = props.personalDetailsList[actorAccountID] || {}; let actorHint = (login || displayName || '').replace(CONST.REGEX.MERGED_ACCOUNT_PREFIX, ''); const displayAllActors = useMemo(() => props.action.actionName === CONST.REPORT.ACTIONS.TYPE.REPORTPREVIEW && props.iouReport, [props.action.actionName, props.iouReport]); const isWorkspaceActor = ReportUtils.isPolicyExpenseChat(props.report) && (!actorAccountID || displayAllActors); @@ -114,14 +114,16 @@ function ReportActionItemSingle(props) { let secondaryAvatar = {}; const primaryDisplayName = displayName; if (displayAllActors) { - const secondaryUserDetails = props.personalDetailsList[props.iouReport.ownerAccountID] || {}; + // The ownerAccountID and actorAccountID can be the same if the a user requests money back from the IOU's original creator, in that case we need to use managerID to avoid displaying the same user twice + const secondaryAccountId = props.iouReport.ownerAccountID === actorAccountID ? props.iouReport.managerID : props.iouReport.ownerAccountID; + const secondaryUserDetails = props.personalDetailsList[secondaryAccountId] || {}; const secondaryDisplayName = lodashGet(secondaryUserDetails, 'displayName', ''); displayName = `${primaryDisplayName} & ${secondaryDisplayName}`; secondaryAvatar = { - source: UserUtils.getAvatar(secondaryUserDetails.avatar, props.iouReport.ownerAccountID), + source: UserUtils.getAvatar(secondaryUserDetails.avatar, secondaryAccountId), type: CONST.ICON_TYPE_AVATAR, name: secondaryDisplayName, - id: props.iouReport.ownerAccountID, + id: secondaryAccountId, }; } else if (!isWorkspaceActor) { secondaryAvatar = ReportUtils.getIcons(props.report, {})[props.report.isOwnPolicyExpenseChat ? 0 : 1]; @@ -198,6 +200,7 @@ function ReportActionItemSingle(props) { source={icon.source} type={icon.type} name={icon.name} + fallbackIcon={fallbackIcon} />
      diff --git a/src/pages/home/report/ReportActionsList.js b/src/pages/home/report/ReportActionsList.js index f3f40d34a0f5..0163a7ff2b4f 100644 --- a/src/pages/home/report/ReportActionsList.js +++ b/src/pages/home/report/ReportActionsList.js @@ -32,6 +32,9 @@ const propTypes = { /** The ID of the most recent IOU report action connected with the shown report */ mostRecentIOUReportActionID: PropTypes.string, + /** The report metadata loading states */ + isLoadingReportActions: PropTypes.bool, + /** Are we loading more report actions? */ isLoadingMoreReportActions: PropTypes.bool, @@ -61,6 +64,7 @@ const defaultProps = { personalDetails: {}, onScroll: () => {}, mostRecentIOUReportActionID: '', + isLoadingReportActions: false, isLoadingMoreReportActions: false, ...withCurrentUserPersonalDetailsDefaultProps, }; @@ -96,6 +100,8 @@ function isMessageUnread(message, lastReadTime) { function ReportActionsList({ report, + isLoadingReportActions, + isLoadingMoreReportActions, sortedReportActions, windowHeight, onScroll, @@ -117,10 +123,11 @@ function ReportActionsList({ const scrollingVerticalOffset = useRef(0); const readActionSkipped = useRef(false); const reportActionSize = useRef(sortedReportActions.length); + const firstRenderRef = useRef(true); - // Considering that renderItem is enclosed within a useCallback, marking it as "read" twice will retain the value as "true," preventing the useCallback from re-executing. - // However, if we create and listen to an object, it will lead to a new useCallback execution. - const [messageManuallyMarked, setMessageManuallyMarked] = useState({read: false}); + // This state is used to force a re-render when the user manually marks a message as unread + // by using a timestamp you can force re-renders without having to worry about if another message was marked as unread before + const [messageManuallyMarkedUnread, setMessageManuallyMarkedUnread] = useState(0); const [isFloatingMessageCounterVisible, setIsFloatingMessageCounterVisible] = useState(false); const animatedStyles = useAnimatedStyle(() => ({ opacity: opacity.value, @@ -129,7 +136,6 @@ function ReportActionsList({ useEffect(() => { opacity.value = withTiming(1, {duration: 100}); }, [opacity]); - const [skeletonViewHeight, setSkeletonViewHeight] = useState(0); useEffect(() => { // If the reportID changes, we reset the userActiveSince to null, we need to do it because @@ -167,14 +173,14 @@ function ReportActionsList({ useEffect(() => { const didManuallyMarkReportAsUnread = report.lastReadTime < DateUtils.getDBTime() && ReportUtils.isUnread(report); - if (!didManuallyMarkReportAsUnread) { - setMessageManuallyMarked({read: false}); + if (didManuallyMarkReportAsUnread) { + // Clearing the current unread marker so that it can be recalculated + setCurrentUnreadMarker(null); + setMessageManuallyMarkedUnread(new Date().getTime()); return; } - // Clearing the current unread marker so that it can be recalculated - setCurrentUnreadMarker(null); - setMessageManuallyMarked({read: true}); + setMessageManuallyMarkedUnread(0); // We only care when a new lastReadTime is set in the report // eslint-disable-next-line react-hooks/exhaustive-deps @@ -281,7 +287,7 @@ function ReportActionsList({ const isCurrentMessageUnread = isMessageUnread(reportAction, report.lastReadTime); shouldDisplayNewMarker = isCurrentMessageUnread && !isMessageUnread(nextMessage, report.lastReadTime); - if (!messageManuallyMarked.read) { + if (!messageManuallyMarkedUnread) { shouldDisplayNewMarker = shouldDisplayNewMarker && reportAction.actorAccountID !== Report.getCurrentUserAccountID(); } const canDisplayMarker = scrollingVerticalOffset.current < MSG_VISIBLE_THRESHOLD ? reportAction.created < userActiveSince.current : true; @@ -305,7 +311,7 @@ function ReportActionsList({ /> ); }, - [report, hasOutstandingIOU, sortedReportActions, mostRecentIOUReportActionID, messageManuallyMarked, shouldHideThreadDividerLine, currentUnreadMarker], + [report, hasOutstandingIOU, sortedReportActions, mostRecentIOUReportActionID, messageManuallyMarkedUnread, shouldHideThreadDividerLine, currentUnreadMarker], ); // Native mobile does not render updates flatlist the changes even though component did update called. @@ -314,6 +320,36 @@ function ReportActionsList({ const hideComposer = ReportUtils.shouldDisableWriteActions(report); const shouldShowReportRecipientLocalTime = ReportUtils.canShowReportRecipientLocalTime(personalDetailsList, report, currentUserPersonalDetails.accountID) && !isComposerFullSize; + const renderFooter = useCallback(() => { + // Skip this hook on the first render, as we are not sure if more actions are going to be loaded + // Therefore showing the skeleton on footer might be misleading + if (firstRenderRef.current) { + firstRenderRef.current = false; + return null; + } + + if (isLoadingMoreReportActions) { + return ; + } + + // Make sure the oldest report action loaded is not the first. This is so we do not show the + // skeleton view above the created action in a newly generated optimistic chat or one with not + // that many comments. + const lastReportAction = _.last(sortedReportActions) || {}; + if (isLoadingReportActions && lastReportAction.actionName !== CONST.REPORT.ACTIONS.TYPE.CREATED) { + return ; + } + + return null; + }, [isLoadingMoreReportActions, isLoadingReportActions, sortedReportActions, isOffline]); + + const onLayoutInner = useCallback( + (event) => { + onLayout(event); + }, + [onLayout], + ); + return ( <> { - if (report.isLoadingMoreReportActions) { - return ; - } - - // Make sure the oldest report action loaded is not the first. This is so we do not show the - // skeleton view above the created action in a newly generated optimistic chat or one with not - // that many comments. - const lastReportAction = _.last(sortedReportActions) || {}; - if (report.isLoadingReportActions && lastReportAction.actionName !== CONST.REPORT.ACTIONS.TYPE.CREATED) { - return ( - - ); - } - - return null; - }} + ListFooterComponent={renderFooter} keyboardShouldPersistTaps="handled" - onLayout={(event) => { - setSkeletonViewHeight(event.nativeEvent.layout.height); - onLayout(event); - }} + onLayout={onLayoutInner} onScroll={trackVerticalScrolling} extraData={extraData} /> diff --git a/src/pages/home/report/ReportActionsView.js b/src/pages/home/report/ReportActionsView.js index a694c4996438..f58c6644cd47 100755 --- a/src/pages/home/report/ReportActionsView.js +++ b/src/pages/home/report/ReportActionsView.js @@ -19,7 +19,7 @@ import * as ReportActionsUtils from '../../../libs/ReportActionsUtils'; import reportPropTypes from '../../reportPropTypes'; import PopoverReactionList from './ReactionList/PopoverReactionList'; import getIsReportFullyVisible from '../../../libs/getIsReportFullyVisible'; -import ReportScreenContext from '../ReportScreenContext'; +import {ReactionListContext} from '../ReportScreenContext'; const propTypes = { /** The report currently being looked at */ @@ -28,6 +28,12 @@ const propTypes = { /** Array of report actions for this report */ reportActions: PropTypes.arrayOf(PropTypes.shape(reportActionPropTypes)), + /** The report metadata loading states */ + isLoadingReportActions: PropTypes.bool, + + /** The report actions are loading more data */ + isLoadingMoreReportActions: PropTypes.bool, + /** Whether the composer is full size */ /* eslint-disable-next-line react/no-unused-prop-types */ isComposerFullSize: PropTypes.bool.isRequired, @@ -51,13 +57,13 @@ const propTypes = { const defaultProps = { reportActions: [], policy: null, + isLoadingReportActions: false, + isLoadingMoreReportActions: false, }; function ReportActionsView(props) { - const context = useContext(ReportScreenContext); - useCopySelectionHelper(); - + const reactionListRef = useContext(ReactionListContext); const didLayout = useRef(false); const didSubscribeToReportTypingEvents = useRef(false); const hasCachedActions = useRef(_.size(props.reportActions) > 0); @@ -138,7 +144,7 @@ function ReportActionsView(props) { */ const loadMoreChats = () => { // Only fetch more if we are not already fetching so that we don't initiate duplicate requests. - if (props.report.isLoadingMoreReportActions) { + if (props.isLoadingMoreReportActions) { return; } @@ -185,11 +191,12 @@ function ReportActionsView(props) { onLayout={recordTimeToMeasureItemLayout} sortedReportActions={props.reportActions} mostRecentIOUReportActionID={mostRecentIOUReportActionID.current} - isLoadingMoreReportActions={props.report.isLoadingMoreReportActions} + isLoadingReportActions={props.isLoadingReportActions} + isLoadingMoreReportActions={props.isLoadingMoreReportActions} loadMoreChats={loadMoreChats} policy={props.policy} /> - + ); } @@ -215,11 +222,11 @@ function arePropsEqual(oldProps, newProps) { return false; } - if (oldProps.report.isLoadingMoreReportActions !== newProps.report.isLoadingMoreReportActions) { + if (oldProps.isLoadingMoreReportActions !== newProps.isLoadingMoreReportActions) { return false; } - if (oldProps.report.isLoadingReportActions !== newProps.report.isLoadingReportActions) { + if (oldProps.isLoadingReportActions !== newProps.isLoadingReportActions) { return false; } diff --git a/src/pages/home/report/ReportFooter.js b/src/pages/home/report/ReportFooter.js index 8d92c09b7a6e..51a8490162e5 100644 --- a/src/pages/home/report/ReportFooter.js +++ b/src/pages/home/report/ReportFooter.js @@ -11,6 +11,7 @@ import ArchivedReportFooter from '../../../components/ArchivedReportFooter'; import compose from '../../../libs/compose'; import ONYXKEYS from '../../../ONYXKEYS'; import withWindowDimensions, {windowDimensionsPropTypes} from '../../../components/withWindowDimensions'; +import useNetwork from '../../../hooks/useNetwork'; import styles from '../../../styles/styles'; import variables from '../../../styles/variables'; import reportActionPropTypes from './reportActionPropTypes'; @@ -25,9 +26,6 @@ const propTypes = { /** Report actions for the current report */ reportActions: PropTypes.arrayOf(PropTypes.shape(reportActionPropTypes)), - /** Offline status */ - isOffline: PropTypes.bool.isRequired, - /** Callback fired when the comment is submitted */ onSubmitComment: PropTypes.func, @@ -53,7 +51,8 @@ const defaultProps = { }; function ReportFooter(props) { - const chatFooterStyles = {...styles.chatFooter, minHeight: !props.isOffline ? CONST.CHAT_FOOTER_MIN_HEIGHT : 0}; + const {isOffline} = useNetwork(); + const chatFooterStyles = {...styles.chatFooter, minHeight: !isOffline ? CONST.CHAT_FOOTER_MIN_HEIGHT : 0}; const isArchivedRoom = ReportUtils.isArchivedRoom(props.report); const isAnonymousUser = Session.isAnonymousUser(); @@ -102,5 +101,6 @@ export default compose( withWindowDimensions, withOnyx({ shouldShowComposeInput: {key: ONYXKEYS.SHOULD_SHOW_COMPOSE_INPUT}, + initialValue: false, }), )(ReportFooter); diff --git a/src/pages/home/report/ReportTypingIndicator.js b/src/pages/home/report/ReportTypingIndicator.js index 4de649c7eb49..db97f712d65f 100755 --- a/src/pages/home/report/ReportTypingIndicator.js +++ b/src/pages/home/report/ReportTypingIndicator.js @@ -74,6 +74,7 @@ export default compose( withOnyx({ userTypingStatuses: { key: ({reportID}) => `${ONYXKEYS.COLLECTION.REPORT_USER_IS_TYPING}${reportID}`, + initialValue: {}, }, }), )(ReportTypingIndicator); diff --git a/src/pages/home/report/withReportAndReportActionOrNotFound.js b/src/pages/home/report/withReportAndReportActionOrNotFound.js index 9bf3e73e761c..b4346504b327 100644 --- a/src/pages/home/report/withReportAndReportActionOrNotFound.js +++ b/src/pages/home/report/withReportAndReportActionOrNotFound.js @@ -6,6 +6,7 @@ import getComponentDisplayName from '../../../libs/getComponentDisplayName'; import NotFoundPage from '../../ErrorPage/NotFoundPage'; import ONYXKEYS from '../../../ONYXKEYS'; import reportPropTypes from '../../reportPropTypes'; +import reportMetadataPropTypes from '../../reportMetadataPropTypes'; import reportActionPropTypes from './reportActionPropTypes'; import FullscreenLoadingIndicator from '../../../components/FullscreenLoadingIndicator'; import * as ReportUtils from '../../../libs/ReportUtils'; @@ -23,6 +24,9 @@ export default function (WrappedComponent) { /** The report currently being looked at */ report: reportPropTypes, + /** The report metadata */ + reportMetadata: reportMetadataPropTypes, + /** Array of report actions for this report */ reportActions: PropTypes.shape(reportActionPropTypes), @@ -62,6 +66,10 @@ export default function (WrappedComponent) { forwardedRef: () => {}, reportActions: {}, report: {}, + reportMetadata: { + isLoadingReportActions: false, + isLoadingMoreReportActions: false, + }, policies: {}, betas: [], isLoadingReportData: true, @@ -94,7 +102,7 @@ export default function (WrappedComponent) { // Perform all the loading checks const isLoadingReport = props.isLoadingReportData && (_.isEmpty(props.report) || !props.report.reportID); - const isLoadingReportAction = _.isEmpty(props.reportActions) || (props.report.isLoadingReportActions && _.isEmpty(getReportAction())); + const isLoadingReportAction = _.isEmpty(props.reportActions) || (props.reportMetadata.isLoadingReportActions && _.isEmpty(getReportAction())); const shouldHideReport = !isLoadingReport && (_.isEmpty(props.report) || !props.report.reportID || !ReportUtils.canAccessReport(props.report, props.policies, props.betas)); if ((isLoadingReport || isLoadingReportAction) && !shouldHideReport) { @@ -135,6 +143,9 @@ export default function (WrappedComponent) { report: { key: ({route}) => `${ONYXKEYS.COLLECTION.REPORT}${route.params.reportID}`, }, + reportMetadata: { + key: ({route}) => `${ONYXKEYS.COLLECTION.REPORT_METADATA}${route.params.reportID}`, + }, isLoadingReportData: { key: ONYXKEYS.IS_LOADING_REPORT_DATA, }, diff --git a/src/pages/home/sidebar/PressableAvatarWithIndicator.js b/src/pages/home/sidebar/PressableAvatarWithIndicator.js index ef6e663ce705..7b240c108e4e 100644 --- a/src/pages/home/sidebar/PressableAvatarWithIndicator.js +++ b/src/pages/home/sidebar/PressableAvatarWithIndicator.js @@ -52,6 +52,7 @@ function PressableAvatarWithIndicator({isCreateMenuOpen, currentUserPersonalDeta diff --git a/src/pages/home/sidebar/SidebarLinks.js b/src/pages/home/sidebar/SidebarLinks.js index 37be5967f997..2b82650a0729 100644 --- a/src/pages/home/sidebar/SidebarLinks.js +++ b/src/pages/home/sidebar/SidebarLinks.js @@ -1,6 +1,6 @@ /* eslint-disable rulesdir/onyx-props-must-have-default */ import React from 'react'; -import {View} from 'react-native'; +import {View, InteractionManager} from 'react-native'; import _ from 'underscore'; import PropTypes from 'prop-types'; import styles from '../../../styles/styles'; @@ -77,6 +77,13 @@ class SidebarLinks extends React.PureComponent { SidebarUtils.setIsSidebarLoadedReady(); this.isSidebarLoaded = true; + // Eagerly set the locale on date-fns, it helps navigating to the report screen faster + InteractionManager.runAfterInteractions(() => { + requestAnimationFrame(() => { + this.props.updateLocale(); + }); + }); + let modal = {}; this.unsubscribeOnyxModal = onyxSubscribe({ key: ONYXKEYS.MODAL, @@ -134,10 +141,11 @@ class SidebarLinks extends React.PureComponent { // or when clicking the active LHN row on large screens // or when continuously clicking different LHNs, only apply to small screen // since getTopmostReportId always returns on other devices + const reportActionID = Navigation.getTopmostReportActionId(); if ( this.props.isCreateMenuOpen || - (!this.props.isSmallScreenWidth && this.props.isActiveReport(option.reportID) && !Navigation.getTopmostReportActionId()) || - (this.props.isSmallScreenWidth && Navigation.getTopmostReportId()) + (option.reportID === Navigation.getTopmostReportId() && !reportActionID) || + (this.props.isSmallScreenWidth && this.props.isActiveReport(option.reportID) && !reportActionID) ) { return; } @@ -195,4 +203,5 @@ class SidebarLinks extends React.PureComponent { SidebarLinks.propTypes = propTypes; SidebarLinks.defaultProps = defaultProps; export default compose(withLocalize, withWindowDimensions)(SidebarLinks); + export {basePropTypes}; diff --git a/src/pages/iou/MoneyRequestDatePage.js b/src/pages/iou/MoneyRequestDatePage.js index e7607277899e..65654aa8098a 100644 --- a/src/pages/iou/MoneyRequestDatePage.js +++ b/src/pages/iou/MoneyRequestDatePage.js @@ -61,10 +61,10 @@ function MoneyRequestDatePage({iou, route, selectedTab}) { IOU.resetMoneyRequestInfo(moneyRequestId); } - if (!isDistanceRequest && (_.isEmpty(iou.participantAccountIDs) || (iou.amount === 0 && !iou.receiptPath) || shouldReset)) { + if (!isDistanceRequest && (_.isEmpty(iou.participants) || (iou.amount === 0 && !iou.receiptPath) || shouldReset)) { Navigation.goBack(ROUTES.MONEY_REQUEST.getRoute(iouType, reportID), true); } - }, [iou.id, iou.participantAccountIDs, iou.amount, iou.receiptPath, iouType, reportID, isDistanceRequest]); + }, [iou.id, iou.participants, iou.amount, iou.receiptPath, iouType, reportID, isDistanceRequest]); function navigateBack() { Navigation.goBack(ROUTES.MONEY_REQUEST_CONFIRMATION.getRoute(iouType, reportID)); diff --git a/src/pages/iou/MoneyRequestDescriptionPage.js b/src/pages/iou/MoneyRequestDescriptionPage.js index 8d274a77a19a..6570cffb58d4 100644 --- a/src/pages/iou/MoneyRequestDescriptionPage.js +++ b/src/pages/iou/MoneyRequestDescriptionPage.js @@ -83,10 +83,10 @@ function MoneyRequestDescriptionPage({iou, route, selectedTab}) { IOU.resetMoneyRequestInfo(moneyRequestId); } - if (!isDistanceRequest && (_.isEmpty(iou.participantAccountIDs) || (iou.amount === 0 && !iou.receiptPath) || shouldReset)) { + if (!isDistanceRequest && (_.isEmpty(iou.participants) || (iou.amount === 0 && !iou.receiptPath) || shouldReset)) { Navigation.goBack(ROUTES.MONEY_REQUEST.getRoute(iouType, reportID), true); } - }, [iou.id, iou.participantAccountIDs, iou.amount, iou.receiptPath, iouType, reportID, isDistanceRequest]); + }, [iou.id, iou.participants, iou.amount, iou.receiptPath, iouType, reportID, isDistanceRequest]); function navigateBack() { Navigation.goBack(ROUTES.MONEY_REQUEST_CONFIRMATION.getRoute(iouType, reportID)); diff --git a/src/pages/iou/MoneyRequestEditWaypointPage.js b/src/pages/iou/MoneyRequestEditWaypointPage.js new file mode 100644 index 000000000000..68f85848a69e --- /dev/null +++ b/src/pages/iou/MoneyRequestEditWaypointPage.js @@ -0,0 +1,32 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import WaypointEditor from './WaypointEditor'; + +const propTypes = { + /** Route params */ + route: PropTypes.shape({ + params: PropTypes.shape({ + /** Thread reportID */ + threadReportID: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), + + /** ID of the transaction being edited */ + transactionID: PropTypes.string, + + /** Index of the waypoint being edited */ + waypointIndex: PropTypes.string, + }), + }), +}; + +const defaultProps = { + route: {}, +}; + +function MoneyRequestEditWaypointPage({route}) { + return ; +} + +MoneyRequestEditWaypointPage.displayName = 'MoneyRequestEditWaypointPage'; +MoneyRequestEditWaypointPage.propTypes = propTypes; +MoneyRequestEditWaypointPage.defaultProps = defaultProps; +export default MoneyRequestEditWaypointPage; diff --git a/src/pages/iou/MoneyRequestMerchantPage.js b/src/pages/iou/MoneyRequestMerchantPage.js index 9d4ce5cc367c..af20930ffe8d 100644 --- a/src/pages/iou/MoneyRequestMerchantPage.js +++ b/src/pages/iou/MoneyRequestMerchantPage.js @@ -58,10 +58,10 @@ function MoneyRequestMerchantPage({iou, route}) { IOU.resetMoneyRequestInfo(moneyRequestId); } - if (_.isEmpty(iou.participantAccountIDs) || (iou.amount === 0 && !iou.receiptPath) || shouldReset) { + if (_.isEmpty(iou.participants) || (iou.amount === 0 && !iou.receiptPath) || shouldReset) { Navigation.goBack(ROUTES.MONEY_REQUEST.getRoute(iouType, reportID), true); } - }, [iou.id, iou.participantAccountIDs, iou.amount, iou.receiptPath, iouType, reportID]); + }, [iou.id, iou.participants, iou.amount, iou.receiptPath, iouType, reportID]); function navigateBack() { Navigation.goBack(ROUTES.MONEY_REQUEST_CONFIRMATION.getRoute(iouType, reportID)); diff --git a/src/pages/iou/MoneyRequestSelectorPage.js b/src/pages/iou/MoneyRequestSelectorPage.js index 990ffd4bb590..9e46b1d2d7a2 100644 --- a/src/pages/iou/MoneyRequestSelectorPage.js +++ b/src/pages/iou/MoneyRequestSelectorPage.js @@ -1,6 +1,6 @@ import {withOnyx} from 'react-native-onyx'; import {View} from 'react-native'; -import React, {useState} from 'react'; +import React, {useEffect, useState} from 'react'; import lodashGet from 'lodash/get'; import PropTypes from 'prop-types'; import ONYXKEYS from '../../ONYXKEYS'; @@ -15,13 +15,14 @@ import Navigation from '../../libs/Navigation/Navigation'; import styles from '../../styles/styles'; import ReceiptSelector from './ReceiptSelector'; import * as IOU from '../../libs/actions/IOU'; -import DistanceRequestPage from './DistanceRequestPage'; +import NewDistanceRequestPage from './NewDistanceRequestPage'; import DragAndDropProvider from '../../components/DragAndDrop/Provider'; import OnyxTabNavigator, {TopTab} from '../../libs/Navigation/OnyxTabNavigator'; import NewRequestAmountPage from './steps/NewRequestAmountPage'; import reportPropTypes from '../reportPropTypes'; import * as ReportUtils from '../../libs/ReportUtils'; import themeColors from '../../styles/themes/default'; +import usePrevious from '../../hooks/usePrevious'; const propTypes = { /** React Navigation route */ @@ -69,6 +70,18 @@ function MoneyRequestSelectorPage(props) { IOU.resetMoneyRequestInfo(moneyRequestID); }; + const prevSelectedTab = usePrevious(props.selectedTab); + + useEffect(() => { + if (prevSelectedTab === props.selectedTab) { + return; + } + + resetMoneyRequestInfo(); + // resetMoneyRequestInfo function is not added as dependencies since they don't change between renders + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [props.selectedTab, prevSelectedTab]); + return ( )} @@ -121,7 +133,7 @@ function MoneyRequestSelectorPage(props) { {shouldDisplayDistanceRequest && ( )} diff --git a/src/pages/iou/MoneyRequestTagPage.js b/src/pages/iou/MoneyRequestTagPage.js index 32c21d949f2c..43c0cd9d1480 100644 --- a/src/pages/iou/MoneyRequestTagPage.js +++ b/src/pages/iou/MoneyRequestTagPage.js @@ -6,6 +6,7 @@ import {withOnyx} from 'react-native-onyx'; import compose from '../../libs/compose'; import ROUTES from '../../ROUTES'; import * as IOU from '../../libs/actions/IOU'; +import * as PolicyUtils from '../../libs/PolicyUtils'; import Navigation from '../../libs/Navigation/Navigation'; import useLocalize from '../../hooks/useLocalize'; import ScreenWrapper from '../../components/ScreenWrapper'; @@ -36,12 +37,7 @@ const propTypes = { report: reportPropTypes, /** Collection of tags attached to a policy */ - policyTags: PropTypes.objectOf( - PropTypes.shape({ - name: PropTypes.string, - tags: PropTypes.objectOf(tagPropTypes), - }), - ), + policyTags: tagPropTypes, /** Holds data related to Money Request view state, rather than the underlying Money Request data. */ iou: iouPropTypes, @@ -60,8 +56,7 @@ function MoneyRequestTagPage({route, report, policyTags, iou}) { // Fetches the first tag list of the policy const tagListKey = _.first(_.keys(policyTags)); - const tagList = lodashGet(policyTags, tagListKey, {}); - const tagListName = lodashGet(tagList, 'name', translate('common.tag')); + const policyTagListName = PolicyUtils.getTagListName(policyTags) || translate('common.tag'); const navigateBack = () => { Navigation.goBack(ROUTES.MONEY_REQUEST_CONFIRMATION.getRoute(iouType, report.reportID)); @@ -83,10 +78,10 @@ function MoneyRequestTagPage({route, report, policyTags, iou}) { testID={MoneyRequestTagPage.displayName} > - {translate('iou.tagSelection', {tagName: tagListName})} + {translate('iou.tagSelection', {tagName: policyTagListName})} { if (iou.transactionID) { @@ -54,23 +54,20 @@ function DistanceRequestPage({iou, report, route}) { return ( IOU.navigateToNextPage(iou, iouType, report)} /> ); } -DistanceRequestPage.displayName = 'DistanceRequestPage'; -DistanceRequestPage.propTypes = propTypes; -DistanceRequestPage.defaultProps = defaultProps; +NewDistanceRequestPage.displayName = 'NewDistanceRequestPage'; +NewDistanceRequestPage.propTypes = propTypes; +NewDistanceRequestPage.defaultProps = defaultProps; export default withOnyx({ - // We must provide a default value for transactionID here, otherwise the component won't mount - // because withOnyx returns null until all the keys are defined iou: {key: ONYXKEYS.IOU}, report: { - key: ({route}) => `${ONYXKEYS.COLLECTION.REPORT}${lodashGet(route, 'params.reportID', '')}`, + key: ({route}) => `${ONYXKEYS.COLLECTION.REPORT}${lodashGet(route, 'params.reportID')}`, }, -})(DistanceRequestPage); +})(NewDistanceRequestPage); diff --git a/src/pages/iou/WaypointEditorPage.js b/src/pages/iou/NewDistanceRequestWaypointEditorPage.js similarity index 63% rename from src/pages/iou/WaypointEditorPage.js rename to src/pages/iou/NewDistanceRequestWaypointEditorPage.js index 51c03623fb50..47dcbc8e4139 100644 --- a/src/pages/iou/WaypointEditorPage.js +++ b/src/pages/iou/NewDistanceRequestWaypointEditorPage.js @@ -32,18 +32,24 @@ const defaultProps = { // This component is responsible for grabbing the transactionID from the IOU key // You can't use Onyx props in the withOnyx mapping, so we need to set up and access the transactionID here, and then pass it down so that WaypointEditor can subscribe to the transaction. -function WaypointEditorPage({transactionID, route}) { +function NewDistanceRequestWaypointEditorPage({transactionID, route}) { return ( ); } -WaypointEditorPage.displayName = 'WaypointEditorPage'; -WaypointEditorPage.propTypes = propTypes; -WaypointEditorPage.defaultProps = defaultProps; +NewDistanceRequestWaypointEditorPage.displayName = 'NewDistanceRequestWaypointEditorPage'; +NewDistanceRequestWaypointEditorPage.propTypes = propTypes; +NewDistanceRequestWaypointEditorPage.defaultProps = defaultProps; export default withOnyx({ transactionID: {key: ONYXKEYS.IOU, selector: (iou) => iou && iou.transactionID}, -})(WaypointEditorPage); +})(NewDistanceRequestWaypointEditorPage); diff --git a/src/pages/iou/ReceiptSelector/index.js b/src/pages/iou/ReceiptSelector/index.js index a817195fe8a3..b4cf75801a3f 100644 --- a/src/pages/iou/ReceiptSelector/index.js +++ b/src/pages/iou/ReceiptSelector/index.js @@ -1,5 +1,5 @@ -import {View, Text, PixelRatio} from 'react-native'; -import React, {useContext, useState} from 'react'; +import {View, Text, PanResponder, PixelRatio} from 'react-native'; +import React, {useContext, useRef, useState} from 'react'; import lodashGet from 'lodash/get'; import _ from 'underscore'; import PropTypes from 'prop-types'; @@ -8,7 +8,6 @@ import * as IOU from '../../../libs/actions/IOU'; import reportPropTypes from '../../reportPropTypes'; import CONST from '../../../CONST'; import ReceiptUpload from '../../../../assets/images/receipt-upload.svg'; -import PressableWithFeedback from '../../../components/Pressable/PressableWithFeedback'; import Button from '../../../components/Button'; import styles from '../../../styles/styles'; import CopyTextToClipboard from '../../../components/CopyTextToClipboard'; @@ -61,7 +60,6 @@ const defaultProps = { }; function ReceiptSelector(props) { - const reportID = lodashGet(props.route, 'params.reportID', ''); const iouType = lodashGet(props.route, 'params.iouType', ''); const [isAttachmentInvalid, setIsAttachmentInvalid] = useState(false); const [attachmentInvalidReasonTitle, setAttachmentInvalidReasonTitle] = useState(''); @@ -127,9 +125,16 @@ function ReceiptSelector(props) { return; } - IOU.navigateToNextPage(iou, iouType, reportID, report, props.route.path); + IOU.navigateToNextPage(iou, iouType, report, props.route.path); }; + const panResponder = useRef( + PanResponder.create({ + onMoveShouldSetPanResponder: () => true, + onPanResponderTerminationRequest: () => false, + }), + ).current; + return ( {!isDraggingOver ? ( @@ -144,35 +149,37 @@ function ReceiptSelector(props) { height={CONST.RECEIPT.ICON_SIZE} /> - {translate('receipt.upload')} - - {isSmallScreenWidth ? translate('receipt.chooseReceipt') : translate('receipt.dragReceiptBeforeEmail')} - - {isSmallScreenWidth ? null : translate('receipt.dragReceiptAfterEmail')} - + + {translate('receipt.upload')} + + {isSmallScreenWidth ? translate('receipt.chooseReceipt') : translate('receipt.dragReceiptBeforeEmail')} + + {isSmallScreenWidth ? null : translate('receipt.dragReceiptAfterEmail')} + + {({openPicker}) => ( - - + style={[styles.p9]} + onPress={() => { + openPicker({ + onPicked: (file) => { + setReceiptAndNavigate(file, props.iou, props.report); + }, + }); + }} + /> )} diff --git a/src/pages/iou/ReceiptSelector/index.native.js b/src/pages/iou/ReceiptSelector/index.native.js index a9d02a52411e..4de4e9bb9148 100644 --- a/src/pages/iou/ReceiptSelector/index.native.js +++ b/src/pages/iou/ReceiptSelector/index.native.js @@ -246,18 +246,14 @@ function ReceiptSelector({route, report, iou, transactionID, isInTabNavigator}) /> {translate('receipt.takePhoto')} {translate('receipt.cameraAccess')} - - - + style={[styles.p9, styles.pt5]} + onPress={askForPermissions} + />
      )} {permissions === RESULTS.GRANTED && device == null && ( @@ -298,7 +294,7 @@ function ReceiptSelector({route, report, iou, transactionID, isInTabNavigator}) return; } - IOU.navigateToNextPage(iou, iouType, reportID, report, route.path); + IOU.navigateToNextPage(iou, iouType, report, route.path); }) .catch(() => { Log.info('User did not select an image from gallery'); diff --git a/src/pages/iou/WaypointEditor.js b/src/pages/iou/WaypointEditor.js index 3397fa8701b6..54269c197c1c 100644 --- a/src/pages/iou/WaypointEditor.js +++ b/src/pages/iou/WaypointEditor.js @@ -4,7 +4,7 @@ import lodashGet from 'lodash/get'; import {InteractionManager, View} from 'react-native'; import PropTypes from 'prop-types'; import {withOnyx} from 'react-native-onyx'; -import {useIsFocused} from '@react-navigation/native'; +import {useNavigation} from '@react-navigation/native'; import AddressSearch from '../../components/AddressSearch'; import ScreenWrapper from '../../components/ScreenWrapper'; import FullPageNotFoundView from '../../components/BlockingViews/FullPageNotFoundView'; @@ -26,15 +26,18 @@ import transactionPropTypes from '../../components/transactionPropTypes'; import * as ErrorUtils from '../../libs/ErrorUtils'; const propTypes = { - /** The transactionID of the IOU */ - transactionID: PropTypes.string.isRequired, - /** Route params */ route: PropTypes.shape({ params: PropTypes.shape({ /** IOU type */ iouType: PropTypes.string, + /** Thread reportID */ + threadReportID: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), + + /** ID of the transaction being edited */ + transactionID: PropTypes.string, + /** Index of the waypoint being edited */ waypointIndex: PropTypes.string, }), @@ -59,24 +62,22 @@ const propTypes = { }), ), + /* Onyx props */ /** The optimistic transaction for this request */ transaction: transactionPropTypes, }; const defaultProps = { - route: { - params: { - waypointIndex: '', - }, - }, + route: {}, recentWaypoints: [], transaction: {}, }; -function WaypointEditor({transactionID, route: {params: {iouType = '', waypointIndex = ''} = {}} = {}, transaction, recentWaypoints}) { +function WaypointEditor({route: {params: {iouType = '', transactionID = '', waypointIndex = '', threadReportID = 0}} = {}, transaction, recentWaypoints}) { const {windowWidth} = useWindowDimensions(); const [isDeleteStopModalOpen, setIsDeleteStopModalOpen] = useState(false); - const isFocused = useIsFocused(); + const navigation = useNavigation(); + const isFocused = navigation.isFocused(); const {translate} = useLocalize(); const {isOffline} = useNetwork(); const textInput = useRef(null); @@ -97,6 +98,7 @@ function WaypointEditor({transactionID, route: {params: {iouType = '', waypointI }, [parsedWaypointIndex, waypointCount]); const waypointAddress = lodashGet(currentWaypoint, 'address', ''); + const isEditingWaypoint = Boolean(threadReportID); const totalWaypoints = _.size(lodashGet(transaction, 'comment.waypoints', {})); // Hide the menu when there is only start and finish waypoint const shouldShowThreeDotsButton = totalWaypoints > 2; @@ -127,7 +129,7 @@ function WaypointEditor({transactionID, route: {params: {iouType = '', waypointI } }; - const onSubmit = (values) => { + const submit = (values) => { const waypointValue = values[`waypoint${waypointIndex}`] || ''; // Allows letting you set a waypoint to an empty value @@ -162,7 +164,12 @@ function WaypointEditor({transactionID, route: {params: {iouType = '', waypointI lng: values.lng, address: values.address, }; - saveWaypoint(waypoint); + Transaction.saveWaypoint(transactionID, waypointIndex, waypoint, isEditingWaypoint); + + if (isEditingWaypoint) { + Navigation.goBack(ROUTES.REPORT_WITH_ID.getRoute(threadReportID)); + return; + } Navigation.goBack(ROUTES.MONEY_REQUEST_DISTANCE_TAB.getRoute(iouType)); }; @@ -216,7 +223,7 @@ function WaypointEditor({transactionID, route: {params: {iouType = '', waypointI formID={ONYXKEYS.FORMS.WAYPOINT_FORM} enabledWhenOffline validate={validate} - onSubmit={onSubmit} + onSubmit={submit} shouldValidateOnChange={false} shouldValidateOnBlur={false} submitButtonText={translate('common.save')} @@ -257,7 +264,7 @@ WaypointEditor.propTypes = propTypes; WaypointEditor.defaultProps = defaultProps; export default withOnyx({ transaction: { - key: ({transactionID}) => `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, + key: ({route}) => `${ONYXKEYS.COLLECTION.TRANSACTION}${lodashGet(route, 'params.transactionID')}`, selector: (transaction) => (transaction ? {transactionID: transaction.transactionID, comment: {waypoints: lodashGet(transaction, 'comment.waypoints')}} : null), }, recentWaypoints: { diff --git a/src/pages/iou/steps/MoneyRequestAmountForm.js b/src/pages/iou/steps/MoneyRequestAmountForm.js index e08fd5bde881..c4fc29957179 100644 --- a/src/pages/iou/steps/MoneyRequestAmountForm.js +++ b/src/pages/iou/steps/MoneyRequestAmountForm.js @@ -106,7 +106,7 @@ function MoneyRequestAmountForm({amount, currency, isEditing, forwardedRef, onCu }; useEffect(() => { - if (!currency || !amount) { + if (!currency || !_.isNumber(amount)) { return; } const amountAsStringForState = CurrencyUtils.convertToFrontendAmount(amount).toString(); diff --git a/src/pages/iou/steps/MoneyRequestConfirmPage.js b/src/pages/iou/steps/MoneyRequestConfirmPage.js index 015707db71f2..3881221d5c52 100644 --- a/src/pages/iou/steps/MoneyRequestConfirmPage.js +++ b/src/pages/iou/steps/MoneyRequestConfirmPage.js @@ -178,9 +178,10 @@ function MoneyRequestConfirmPage(props) { props.iou.amount, props.iou.currency, props.iou.merchant, + props.iou.billable, ); }, - [props.report, props.iou.created, props.iou.transactionID, props.iou.category, props.iou.tag, props.iou.amount, props.iou.currency, props.iou.merchant], + [props.report, props.iou.created, props.iou.transactionID, props.iou.category, props.iou.tag, props.iou.amount, props.iou.currency, props.iou.merchant, props.iou.billable], ); const createTransaction = useCallback( @@ -393,7 +394,7 @@ export default compose( // eslint-disable-next-line rulesdir/no-multiple-onyx-in-file withOnyx({ policy: { - key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY}${report.policyID}`, + key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY}${report ? report.policyID : '0'}`, }, }), )(MoneyRequestConfirmPage); diff --git a/src/pages/iou/steps/NewRequestAmountPage.js b/src/pages/iou/steps/NewRequestAmountPage.js index 92bc40a2af51..c9e2ca464303 100644 --- a/src/pages/iou/steps/NewRequestAmountPage.js +++ b/src/pages/iou/steps/NewRequestAmountPage.js @@ -1,5 +1,5 @@ import React, {useCallback, useEffect, useRef} from 'react'; -import {InteractionManager, View} from 'react-native'; +import {View} from 'react-native'; import PropTypes from 'prop-types'; import {withOnyx} from 'react-native-onyx'; import {useFocusEffect} from '@react-navigation/native'; @@ -22,6 +22,7 @@ import HeaderWithBackButton from '../../../components/HeaderWithBackButton'; import ScreenWrapper from '../../../components/ScreenWrapper'; import {iouPropTypes, iouDefaultProps} from '../propTypes'; import CONST from '../../../CONST'; +import FullScreenLoadingIndicator from '../../../components/FullscreenLoadingIndicator'; const propTypes = { /** React Navigation route */ @@ -69,22 +70,17 @@ function NewRequestAmountPage({route, iou, report, selectedTab}) { const currency = CurrencyUtils.isValidCurrencyCode(currentCurrency) ? currentCurrency : iou.currency; - const focusTextInput = () => { - // Component may not be initialized due to navigation transitions - // Wait until interactions are complete before trying to focus - InteractionManager.runAfterInteractions(() => { - // Focus text input - if (!textInput.current) { - return; - } - - textInput.current.focus(); - }); - }; + const focusTimeoutRef = useRef(null); useFocusEffect( useCallback(() => { - focusTextInput(); + focusTimeoutRef.current = setTimeout(() => textInput.current && textInput.current.focus(), CONST.ANIMATED_TRANSITION); + return () => { + if (!focusTimeoutRef.current) { + return; + } + clearTimeout(focusTimeoutRef.current); + }; }, []), ); @@ -115,7 +111,7 @@ function NewRequestAmountPage({route, iou, report, selectedTab}) { IOU.resetMoneyRequestInfo(moneyRequestID); } - if (!isDistanceRequestTab && (_.isEmpty(iou.participantAccountIDs) || iou.amount === 0 || shouldReset)) { + if (!isDistanceRequestTab && (_.isEmpty(iou.participants) || iou.amount === 0 || shouldReset)) { Navigation.goBack(ROUTES.MONEY_REQUEST.getRoute(iouType, reportID), true); } } @@ -123,7 +119,7 @@ function NewRequestAmountPage({route, iou, report, selectedTab}) { return () => { prevMoneyRequestID.current = iou.id; }; - }, [iou.participantAccountIDs, iou.amount, iou.id, isEditing, iouType, reportID, isDistanceRequestTab]); + }, [iou.participants, iou.amount, iou.id, isEditing, iouType, reportID, isDistanceRequestTab]); const navigateBack = () => { Navigation.goBack(isEditing ? ROUTES.MONEY_REQUEST_CONFIRMATION.getRoute(iouType, reportID) : ROUTES.HOME); @@ -151,7 +147,7 @@ function NewRequestAmountPage({route, iou, report, selectedTab}) { return; } - IOU.navigateToNextPage(iou, iouType, reportID, report); + IOU.navigateToNextPage(iou, iouType, report); }; const content = ( @@ -165,6 +161,10 @@ function NewRequestAmountPage({route, iou, report, selectedTab}) { /> ); + if (!lodashGet(iou, 'didInitCurrency', false)) { + return ; + } + // ScreenWrapper is only needed in edit mode because we have a dedicated route for the edit amount page (MoneyRequestEditAmountPage). // The rest of the cases this component is rendered through which has it's own ScreenWrapper if (!isEditing) { @@ -175,7 +175,6 @@ function NewRequestAmountPage({route, iou, report, selectedTab}) { {({safeAreaPaddingBottomStyle}) => ( diff --git a/src/pages/reportMetadataPropTypes.js b/src/pages/reportMetadataPropTypes.js new file mode 100644 index 000000000000..a75d71aef7b3 --- /dev/null +++ b/src/pages/reportMetadataPropTypes.js @@ -0,0 +1,9 @@ +import PropTypes from 'prop-types'; + +export default PropTypes.shape({ + /** Are we loading more report actions? */ + isLoadingMoreReportActions: PropTypes.bool, + + /** Flag to check if the report actions data are loading */ + isLoadingReportActions: PropTypes.bool, +}); diff --git a/src/pages/reportPropTypes.js b/src/pages/reportPropTypes.js index da90e0a4ac5c..a2c41b5a8147 100644 --- a/src/pages/reportPropTypes.js +++ b/src/pages/reportPropTypes.js @@ -13,12 +13,6 @@ export default PropTypes.shape({ /** List of icons for report participants */ icons: PropTypes.arrayOf(avatarPropTypes), - /** Are we loading more report actions? */ - isLoadingMoreReportActions: PropTypes.bool, - - /** Flag to check if the report actions data are loading */ - isLoadingReportActions: PropTypes.bool, - /** Whether the user is not an admin of policyExpenseChat chat */ isOwnPolicyExpenseChat: PropTypes.bool, diff --git a/src/pages/settings/InitialSettingsPage.js b/src/pages/settings/InitialSettingsPage.js index d10779210b09..86eff304df9b 100755 --- a/src/pages/settings/InitialSettingsPage.js +++ b/src/pages/settings/InitialSettingsPage.js @@ -348,6 +348,7 @@ function InitialSettingsPage(props) { imageStyles={[styles.avatarXLarge]} source={UserUtils.getAvatar(props.currentUserPersonalDetails.avatar, props.session.accountID)} size={CONST.AVATAR_SIZE.XLARGE} + fallbackIcon={props.currentUserPersonalDetails.fallbackIcon} /> diff --git a/src/pages/settings/Profile/Contacts/ValidateCodeForm/BaseValidateCodeForm.js b/src/pages/settings/Profile/Contacts/ValidateCodeForm/BaseValidateCodeForm.js index bcea33d9c366..300bd23cc2e5 100644 --- a/src/pages/settings/Profile/Contacts/ValidateCodeForm/BaseValidateCodeForm.js +++ b/src/pages/settings/Profile/Contacts/ValidateCodeForm/BaseValidateCodeForm.js @@ -111,6 +111,7 @@ function BaseValidateCodeForm(props) { const resendValidateCode = () => { User.requestContactMethodValidateCode(props.contactMethod); setValidateCode(''); + inputValidateCodeRef.current.clear(); inputValidateCodeRef.current.focus(); }; diff --git a/src/pages/settings/Profile/CustomStatus/StatusPage.js b/src/pages/settings/Profile/CustomStatus/StatusPage.js index 807bd73cecc1..0fa231aec6f1 100644 --- a/src/pages/settings/Profile/CustomStatus/StatusPage.js +++ b/src/pages/settings/Profile/CustomStatus/StatusPage.js @@ -7,9 +7,9 @@ import MenuItemWithTopDescription from '../../../../components/MenuItemWithTopDe import HeaderPageLayout from '../../../../components/HeaderPageLayout'; import * as Expensicons from '../../../../components/Icon/Expensicons'; import withLocalize from '../../../../components/withLocalize'; -import MenuItem from '../../../../components/MenuItem'; import Button from '../../../../components/Button'; import Text from '../../../../components/Text'; +import MenuItem from '../../../../components/MenuItem'; import Navigation from '../../../../libs/Navigation/Navigation'; import * as User from '../../../../libs/actions/User'; import MobileBackgroundImage from '../../../../../assets/images/money-stack.svg'; @@ -34,8 +34,16 @@ function StatusPage({draftStatus, currentUserPersonalDetails}) { const defaultEmoji = draftEmojiCode || currentUserEmojiCode; const defaultText = draftEmojiCode ? draftText : currentUserStatusText; - const customStatus = draftEmojiCode ? `${draftEmojiCode} ${draftText}` : `${currentUserEmojiCode || ''} ${currentUserStatusText || ''}`; const hasDraftStatus = !!draftEmojiCode || !!draftText; + const customStatus = useMemo(() => { + if (draftEmojiCode) { + return `${draftEmojiCode} ${draftText}`; + } + if (currentUserEmojiCode || currentUserStatusText) { + return `${currentUserEmojiCode || ''} ${currentUserStatusText || ''}`; + } + return ''; + }, [draftEmojiCode, draftText, currentUserEmojiCode, currentUserStatusText]); const clearStatus = () => { User.clearCustomStatus(); @@ -77,7 +85,7 @@ function StatusPage({draftStatus, currentUserPersonalDetails}) { backgroundColor={themeColors.PAGE_BACKGROUND_COLORS[SCREENS.SETTINGS.STATUS]} footer={footerComponent} > - + {localize.translate('statusPage.setStatusTitle')} {localize.translate('statusPage.statusExplanation')} @@ -92,10 +100,11 @@ function StatusPage({draftStatus, currentUserPersonalDetails}) { {(!!currentUserEmojiCode || !!currentUserStatusText) && ( )} diff --git a/src/pages/settings/Profile/LoungeAccessPage.js b/src/pages/settings/Profile/LoungeAccessPage.js index c322c8e426f3..81d63592211b 100644 --- a/src/pages/settings/Profile/LoungeAccessPage.js +++ b/src/pages/settings/Profile/LoungeAccessPage.js @@ -69,11 +69,12 @@ function LoungeAccessPage(props) { colors={[`${themeColors.loungeAccessOverlay}00`, themeColors.loungeAccessOverlay]} style={[styles.pAbsolute, styles.w100, styles.h100]} > - + { + const handleAddressChange = useCallback((value, key) => { if (key !== 'country' && key !== 'state') { return; } @@ -126,7 +136,14 @@ function AddressPage({privatePersonalDetails}) { return; } setState(value); - }; + }, []); + + useEffect(() => { + if (!countryFromUrl || countryFromUrl === currentCountry) { + return; + } + handleAddressChange(countryFromUrl, 'country'); + }, [countryFromUrl, handleAddressChange, currentCountry]); return ( - diff --git a/src/pages/settings/Profile/PersonalDetails/CountrySelectionPage.js b/src/pages/settings/Profile/PersonalDetails/CountrySelectionPage.js new file mode 100644 index 000000000000..741974776df1 --- /dev/null +++ b/src/pages/settings/Profile/PersonalDetails/CountrySelectionPage.js @@ -0,0 +1,107 @@ +import React, {useState, useMemo, useCallback} from 'react'; +import PropTypes from 'prop-types'; +import _ from 'underscore'; +import lodashGet from 'lodash/get'; +import Navigation from '../../../../libs/Navigation/Navigation'; +import ScreenWrapper from '../../../../components/ScreenWrapper'; +import HeaderWithBackButton from '../../../../components/HeaderWithBackButton'; +import SelectionList from '../../../../components/SelectionList'; +import searchCountryOptions from '../../../../libs/searchCountryOptions'; +import StringUtils from '../../../../libs/StringUtils'; +import CONST from '../../../../CONST'; +import useLocalize from '../../../../hooks/useLocalize'; + +const propTypes = { + /** Route from navigation */ + route: PropTypes.shape({ + /** Params from the route */ + params: PropTypes.shape({ + /** Currently selected country */ + country: PropTypes.string, + + /** Route to navigate back after selecting a currency */ + backTo: PropTypes.string, + }), + }).isRequired, + + /** Navigation from react-navigation */ + navigation: PropTypes.shape({ + /** getState function retrieves the current navigation state from react-navigation's navigation property */ + getState: PropTypes.func.isRequired, + }).isRequired, +}; + +function CountrySelectionPage({route, navigation}) { + const [searchValue, setSearchValue] = useState(''); + const {translate} = useLocalize(); + const currentCountry = lodashGet(route, 'params.country'); + + const countries = useMemo( + () => + _.map(_.keys(CONST.ALL_COUNTRIES), (countryISO) => { + const countryName = translate(`allCountries.${countryISO}`); + return { + value: countryISO, + keyForList: countryISO, + text: countryName, + isSelected: currentCountry === countryISO, + searchValue: StringUtils.sanitizeString(`${countryISO}${countryName}`), + }; + }), + [translate, currentCountry], + ); + + const searchResults = searchCountryOptions(searchValue, countries); + const headerMessage = searchValue.trim() && !searchResults.length ? translate('common.noResultsFound') : ''; + + const selectCountry = useCallback( + (option) => { + const backTo = lodashGet(route, 'params.backTo', ''); + + // Check the navigation state and "backTo" parameter to decide navigation behavior + if (navigation.getState().routes.length === 1 && _.isEmpty(backTo)) { + // If there is only one route and "backTo" is empty, go back in navigation + Navigation.goBack(); + } else if (!_.isEmpty(backTo) && navigation.getState().routes.length === 1) { + // If "backTo" is not empty and there is only one route, go back to the specific route defined in "backTo" with a country parameter + Navigation.goBack(`${route.params.backTo}?country=${option.value}`); + } else { + // Otherwise, navigate to the specific route defined in "backTo" with a country parameter + Navigation.navigate(`${route.params.backTo}?country=${option.value}`); + } + }, + [route, navigation], + ); + + return ( + + { + const backTo = lodashGet(route, 'params.backTo', ''); + const backToRoute = backTo ? `${backTo}?country=${currentCountry}` : ''; + Navigation.goBack(backToRoute); + }} + /> + + + + ); +} + +CountrySelectionPage.displayName = 'CountrySelectionPage'; +CountrySelectionPage.propTypes = propTypes; + +export default CountrySelectionPage; diff --git a/src/pages/settings/Profile/ProfilePage.js b/src/pages/settings/Profile/ProfilePage.js index 881937213c9b..77e21274cfea 100755 --- a/src/pages/settings/Profile/ProfilePage.js +++ b/src/pages/settings/Profile/ProfilePage.js @@ -132,6 +132,7 @@ function ProfilePage(props) { originalFileName={currentUserDetails.originalFileName} headerTitle={props.translate('profilePage.profileAvatar')} style={[styles.mh5]} + fallbackIcon={lodashGet(currentUserDetails, 'fallbackIcon')} /> {_.map(profileSettingsOptions, (detail, index) => ( diff --git a/src/pages/settings/Report/RoomNamePage.js b/src/pages/settings/Report/RoomNamePage.js index 42d7156660f9..985d83e7fd95 100644 --- a/src/pages/settings/Report/RoomNamePage.js +++ b/src/pages/settings/Report/RoomNamePage.js @@ -2,6 +2,7 @@ import React, {useCallback, useRef} from 'react'; import {withOnyx} from 'react-native-onyx'; import PropTypes from 'prop-types'; import {View} from 'react-native'; +import {useIsFocused} from '@react-navigation/native'; import CONST from '../../../CONST'; import ScreenWrapper from '../../../components/ScreenWrapper'; import HeaderWithBackButton from '../../../components/HeaderWithBackButton'; @@ -48,6 +49,7 @@ function RoomNamePage(props) { const translate = props.translate; const roomNameInputRef = useRef(null); + const isFocused = useIsFocused(); const validate = useCallback( (values) => { @@ -101,6 +103,7 @@ function RoomNamePage(props) { ref={(ref) => (roomNameInputRef.current = ref)} inputID="roomName" defaultValue={report.reportName} + isFocused={isFocused} /> diff --git a/src/pages/settings/Wallet/ExpensifyCardPage.js b/src/pages/settings/Wallet/ExpensifyCardPage.js new file mode 100644 index 000000000000..bc49301e8d80 --- /dev/null +++ b/src/pages/settings/Wallet/ExpensifyCardPage.js @@ -0,0 +1,107 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import {ScrollView, View} from 'react-native'; +import {withOnyx} from 'react-native-onyx'; +import _ from 'underscore'; +import ONYXKEYS from '../../../ONYXKEYS'; +import ROUTES from '../../../ROUTES'; +import NotFoundPage from '../../ErrorPage/NotFoundPage'; +import CardPreview from '../../../components/CardPreview'; +import HeaderWithBackButton from '../../../components/HeaderWithBackButton'; +import MenuItemWithTopDescription from '../../../components/MenuItemWithTopDescription'; +import ScreenWrapper from '../../../components/ScreenWrapper'; +import assignedCardPropTypes from './assignedCardPropTypes'; +import useLocalize from '../../../hooks/useLocalize'; +import * as CurrencyUtils from '../../../libs/CurrencyUtils'; +import Navigation from '../../../libs/Navigation/Navigation'; +import styles from '../../../styles/styles'; +import * as CardUtils from '../../../libs/CardUtils'; + +const propTypes = { + /* Onyx Props */ + cardList: PropTypes.objectOf(assignedCardPropTypes), + + /** Navigation route context info provided by react navigation */ + route: PropTypes.shape({ + params: PropTypes.shape({ + /** domain passed via route /settings/wallet/card/:domain */ + domain: PropTypes.string, + }), + }).isRequired, +}; + +const defaultProps = { + cardList: {}, +}; + +function ExpensifyCardPage({ + cardList, + route: { + params: {domain}, + }, +}) { + const {translate} = useLocalize(); + const domainCards = CardUtils.getDomainCards(cardList)[domain]; + const virtualCard = _.find(domainCards, (card) => card.isVirtual) || {}; + const physicalCard = _.find(domainCards, (card) => !card.isVirtual) || {}; + + if (_.isEmpty(virtualCard) && _.isEmpty(physicalCard)) { + return ; + } + + const formattedAvailableSpendAmount = CurrencyUtils.convertToDisplayString(physicalCard.availableSpend || virtualCard.availableSpend || 0); + + return ( + + {({safeAreaPaddingBottomStyle}) => ( + <> + Navigation.goBack(ROUTES.SETTINGS_WALLET)} + /> + + + + + + + {!_.isEmpty(physicalCard) && ( + + )} + {!_.isEmpty(physicalCard) && ( + + )} + + + )} + + ); +} + +ExpensifyCardPage.propTypes = propTypes; +ExpensifyCardPage.defaultProps = defaultProps; +ExpensifyCardPage.displayName = 'ExpensifyCardPage'; + +export default withOnyx({ + cardList: { + key: ONYXKEYS.CARD_LIST, + }, +})(ExpensifyCardPage); diff --git a/src/pages/settings/Wallet/assignedCardPropTypes.js b/src/pages/settings/Wallet/assignedCardPropTypes.js new file mode 100644 index 000000000000..2f0eb1ad4ec9 --- /dev/null +++ b/src/pages/settings/Wallet/assignedCardPropTypes.js @@ -0,0 +1,20 @@ +import PropTypes from 'prop-types'; +import CONST from '../../../CONST'; + +/** Assigned Card props */ +const assignedCardPropTypes = PropTypes.shape({ + cardID: PropTypes.number, + state: PropTypes.number, + bank: PropTypes.string, + availableSpend: PropTypes.number, + domainName: PropTypes.string, + lastFourPAN: PropTypes.string, + cardName: PropTypes.string, + isVirtual: PropTypes.bool, + fraud: PropTypes.oneOf([CONST.EXPENSIFY_CARD.FRAUD_TYPES.DOMAIN, CONST.EXPENSIFY_CARD.FRAUD_TYPES.USER, CONST.EXPENSIFY_CARD.FRAUD_TYPES.NONE]), + cardholderFirstName: PropTypes.string, + cardholderLastName: PropTypes.string, + errors: PropTypes.objectOf(PropTypes.string), +}); + +export default assignedCardPropTypes; diff --git a/src/pages/signin/ValidateCodeForm/BaseValidateCodeForm.js b/src/pages/signin/ValidateCodeForm/BaseValidateCodeForm.js index 7815976609c5..2b0d27e55abc 100755 --- a/src/pages/signin/ValidateCodeForm/BaseValidateCodeForm.js +++ b/src/pages/signin/ValidateCodeForm/BaseValidateCodeForm.js @@ -52,9 +52,6 @@ const propTypes = { authToken: PropTypes.string, }), - /** Indicates which locale the user currently has selected */ - preferredLocale: PropTypes.string, - /** Information about the network */ network: networkPropTypes.isRequired, @@ -76,7 +73,6 @@ const defaultProps = { session: { authToken: null, }, - preferredLocale: CONST.LOCALES.DEFAULT, }; function BaseValidateCodeForm(props) { @@ -283,11 +279,11 @@ function BaseValidateCodeForm(props) { const accountID = lodashGet(props.credentials, 'accountID'); if (accountID) { - Session.signInWithValidateCode(accountID, validateCode, props.preferredLocale, recoveryCodeOr2faCode); + Session.signInWithValidateCode(accountID, validateCode, recoveryCodeOr2faCode); } else { - Session.signIn(validateCode, recoveryCodeOr2faCode, props.preferredLocale); + Session.signIn(validateCode, recoveryCodeOr2faCode); } - }, [props.account, props.credentials, props.preferredLocale, twoFactorAuthCode, validateCode, props.isUsingRecoveryCode, recoveryCode]); + }, [props.account, props.credentials, twoFactorAuthCode, validateCode, props.isUsingRecoveryCode, recoveryCode]); return ( <> @@ -404,7 +400,6 @@ export default compose( withOnyx({ account: {key: ONYXKEYS.ACCOUNT}, credentials: {key: ONYXKEYS.CREDENTIALS}, - preferredLocale: {key: ONYXKEYS.NVP_PREFERRED_LOCALE}, session: {key: ONYXKEYS.SESSION}, }), withNetwork(), diff --git a/src/pages/workspace/WorkspaceMembersPage.js b/src/pages/workspace/WorkspaceMembersPage.js index 5bb8f9755cd3..9eff4eba47ee 100644 --- a/src/pages/workspace/WorkspaceMembersPage.js +++ b/src/pages/workspace/WorkspaceMembersPage.js @@ -136,7 +136,7 @@ function WorkspaceMembersPage(props) { */ const inviteUser = () => { setSearchValue(''); - Navigation.navigate(ROUTES.WORKSPACE_INITIAL.getRoute(props.route.params.policyID)); + Navigation.navigate(ROUTES.WORKSPACE_INVITE.getRoute(props.route.params.policyID)); }; /** diff --git a/src/pages/workspace/WorkspaceNewRoomPage.js b/src/pages/workspace/WorkspaceNewRoomPage.js index 5e4aeda6262e..d6f58a1b66ad 100644 --- a/src/pages/workspace/WorkspaceNewRoomPage.js +++ b/src/pages/workspace/WorkspaceNewRoomPage.js @@ -5,6 +5,7 @@ import {withOnyx} from 'react-native-onyx'; import PropTypes from 'prop-types'; import withNavigationFocus, {withNavigationFocusPropTypes} from '../../components/withNavigationFocus'; import * as Report from '../../libs/actions/Report'; +import * as App from '../../libs/actions/App'; import useLocalize from '../../hooks/useLocalize'; import styles from '../../styles/styles'; import RoomNameInput from '../../components/RoomNameInput'; @@ -144,7 +145,12 @@ function WorkspaceNewRoomPage(props) { ); return ( - + App.createWorkspaceAndNavigateToIt('', false, '', false, false)} + > ({ marginRight: 20, justifyContent: 'center', alignItems: 'center', - padding: 40, + paddingVertical: 40, gap: 4, flex: 1, }), + receiptViewTextContainer: { + paddingHorizontal: 40, + ...sizing.w100, + }, + cameraView: { flex: 1, overflow: 'hidden', @@ -3390,7 +3395,7 @@ const styles = (theme) => ({ }, signInIconButton: { - padding: 2, + paddingVertical: 2, }, googleButtonContainer: { @@ -3515,6 +3520,10 @@ const styles = (theme) => ({ backgroundColor: theme.border, }, + reportContainerBorderRadius: { + borderRadius: variables.componentBorderRadiusLarge, + }, + reportPreviewBoxBody: { padding: 16, }, @@ -3672,6 +3681,28 @@ const styles = (theme) => ({ height: 30, width: '100%', }, + + walletCard: { + borderRadius: variables.componentBorderRadiusLarge, + position: 'relative', + alignSelf: 'center', + overflow: 'hidden', + }, + + walletCardNumber: { + color: theme.text, + fontSize: variables.fontSizeNormal, + }, + + walletCardHolder: { + position: 'absolute', + left: 16, + bottom: 16, + width: variables.cardNameWidth, + color: theme.text, + fontSize: variables.fontSizeSmall, + lineHeight: variables.lineHeightLarge, + }, }); // For now we need to export the styles function that takes the theme as an argument diff --git a/src/styles/themes/default.js b/src/styles/themes/default.js index ffa34132c081..75db4be30e2b 100644 --- a/src/styles/themes/default.js +++ b/src/styles/themes/default.js @@ -83,6 +83,7 @@ const darkTheme = { QRLogo: colors.green400, starDefaultBG: 'rgb(254, 228, 94)', loungeAccessOverlay: colors.blue800, + mapAttributionText: colors.black, }; darkTheme.PAGE_BACKGROUND_COLORS = { diff --git a/src/styles/themes/light.js b/src/styles/themes/light.js index cebd325ee4eb..8bc149c5af08 100644 --- a/src/styles/themes/light.js +++ b/src/styles/themes/light.js @@ -81,6 +81,7 @@ const lightTheme = { QRLogo: colors.green400, starDefaultBG: 'rgb(254, 228, 94)', loungeAccessOverlay: colors.blue800, + mapAttributionText: colors.black, }; lightTheme.PAGE_BACKGROUND_COLORS = { diff --git a/src/styles/variables.ts b/src/styles/variables.ts index 606b5bed4543..9ee9b64e6467 100644 --- a/src/styles/variables.ts +++ b/src/styles/variables.ts @@ -159,4 +159,8 @@ export default { moneyRequestSkeletonHeight: 107, distanceScrollEventThrottle: 16, + + cardPreviewHeight: 148, + cardPreviewWidth: 235, + cardNameWidth: 156, } as const; diff --git a/src/types/onyx/OnyxCommon.ts b/src/types/onyx/OnyxCommon.ts index 36e9c6ae74ea..db82edcb98ff 100644 --- a/src/types/onyx/OnyxCommon.ts +++ b/src/types/onyx/OnyxCommon.ts @@ -4,7 +4,7 @@ import CONST from '../../CONST'; type PendingAction = ValueOf; -type ErrorFields = Record>; +type ErrorFields = Record | null>; type Errors = Record; diff --git a/src/types/onyx/OriginalMessage.ts b/src/types/onyx/OriginalMessage.ts index 8ed25cb286b0..369ff44773ab 100644 --- a/src/types/onyx/OriginalMessage.ts +++ b/src/types/onyx/OriginalMessage.ts @@ -137,4 +137,4 @@ type OriginalMessage = | OriginalMessagePolicyTask; export default OriginalMessage; -export type {Reaction}; +export type {Reaction, ChronosOOOEvent}; diff --git a/src/types/onyx/RecentWaypoints.ts b/src/types/onyx/RecentWaypoint.ts similarity index 76% rename from src/types/onyx/RecentWaypoints.ts rename to src/types/onyx/RecentWaypoint.ts index 75780ef861e5..79aded8ede98 100644 --- a/src/types/onyx/RecentWaypoints.ts +++ b/src/types/onyx/RecentWaypoint.ts @@ -1,4 +1,4 @@ -type RecentWaypoints = { +type RecentWaypoint = { /** The full address of the waypoint */ address: string; @@ -9,4 +9,4 @@ type RecentWaypoints = { lng: number; }; -export default RecentWaypoints; +export default RecentWaypoint; diff --git a/src/types/onyx/Report.ts b/src/types/onyx/Report.ts index 88caa683305d..46e51fe41238 100644 --- a/src/types/onyx/Report.ts +++ b/src/types/onyx/Report.ts @@ -12,12 +12,6 @@ type Report = { /** List of icons for report participants */ icons?: OnyxCommon.Icon[]; - /** Are we loading more report actions? */ - isLoadingMoreReportActions?: boolean; - - /** Flag to check if the report actions data are loading */ - isLoadingReportActions?: boolean; - /** Whether the user is not an admin of policyExpenseChat chat */ isOwnPolicyExpenseChat?: boolean; diff --git a/src/types/onyx/ReportMetadata.ts b/src/types/onyx/ReportMetadata.ts new file mode 100644 index 000000000000..3e389c8cff4f --- /dev/null +++ b/src/types/onyx/ReportMetadata.ts @@ -0,0 +1,9 @@ +type ReportMetadata = { + /** Are we loading more report actions? */ + isLoadingMoreReportActions?: boolean; + + /** Flag to check if the report actions data are loading */ + isLoadingReportActions?: boolean; +}; + +export default ReportMetadata; diff --git a/src/types/onyx/Response.ts b/src/types/onyx/Response.ts index c501034e971c..255ac6d9bae4 100644 --- a/src/types/onyx/Response.ts +++ b/src/types/onyx/Response.ts @@ -3,9 +3,10 @@ import {OnyxUpdate} from 'react-native-onyx'; type Response = { previousUpdateID?: number | string; lastUpdateID?: number | string; - jsonCode?: number; + jsonCode?: number | string; onyxData?: OnyxUpdate[]; requestID?: string; + message?: string; }; export default Response; diff --git a/src/types/onyx/Transaction.ts b/src/types/onyx/Transaction.ts index ea0b178444b5..dd53024a5426 100644 --- a/src/types/onyx/Transaction.ts +++ b/src/types/onyx/Transaction.ts @@ -1,18 +1,23 @@ import {ValueOf} from 'type-fest'; import * as OnyxCommon from './OnyxCommon'; import CONST from '../../CONST'; +import RecentWaypoint from './RecentWaypoint'; +type WaypointCollection = Record; type Comment = { comment?: string; + waypoints?: WaypointCollection; }; +type GeometryType = 'LineString'; + type Geometry = { - coordinates: number[][]; - type: 'LineString'; + coordinates: number[][] | null; + type?: GeometryType; }; type Route = { - distance: number; + distance: number | null; geometry: Geometry; }; @@ -20,11 +25,13 @@ type Routes = Record; type Transaction = { amount: number; + billable: boolean; category: string; comment: Comment; created: string; currency: string; errors: OnyxCommon.Errors; + errorFields?: OnyxCommon.ErrorFields; // The name of the file used for a receipt (formerly receiptFilename) filename?: string; merchant: string; @@ -32,6 +39,9 @@ type Transaction = { modifiedCreated?: string; modifiedCurrency?: string; pendingAction: OnyxCommon.PendingAction; + pendingFields: { + comment: string; + }; receipt: { receiptID?: number; source?: string; @@ -44,3 +54,4 @@ type Transaction = { }; export default Transaction; +export type {WaypointCollection}; diff --git a/src/types/onyx/index.ts b/src/types/onyx/index.ts index 069909153096..e50925e7adf2 100644 --- a/src/types/onyx/index.ts +++ b/src/types/onyx/index.ts @@ -36,12 +36,13 @@ import PolicyMember from './PolicyMember'; import Policy from './Policy'; import PolicyCategory from './PolicyCategory'; import Report from './Report'; +import ReportMetadata from './ReportMetadata'; import ReportAction from './ReportAction'; import ReportActionReactions from './ReportActionReactions'; import SecurityGroup from './SecurityGroup'; import Transaction from './Transaction'; import Form, {AddDebitCardForm} from './Form'; -import RecentWaypoints from './RecentWaypoints'; +import RecentWaypoint from './RecentWaypoint'; import RecentlyUsedCategories from './RecentlyUsedCategories'; import RecentlyUsedTags from './RecentlyUsedTags'; import PolicyTag from './PolicyTag'; @@ -84,6 +85,7 @@ export type { Policy, PolicyCategory, Report, + ReportMetadata, ReportAction, ReportActionReactions, SecurityGroup, @@ -91,8 +93,8 @@ export type { Form, AddDebitCardForm, OnyxUpdatesFromServer, + RecentWaypoint, OnyxUpdateEvent, - RecentWaypoints, RecentlyUsedCategories, RecentlyUsedTags, PolicyTag, diff --git a/tests/actions/ReportTest.js b/tests/actions/ReportTest.js index 62109089665c..06d8304111cb 100644 --- a/tests/actions/ReportTest.js +++ b/tests/actions/ReportTest.js @@ -503,7 +503,9 @@ describe('actions/Report', () => { it('should show a notification for report action updates with shouldNotify', () => { const TEST_USER_ACCOUNT_ID = 1; const REPORT_ID = '1'; - const REPORT_ACTION = {}; + const REPORT_ACTION = { + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + }; // Setup user and pusher listeners return TestHelper.signInWithTestUser(TEST_USER_ACCOUNT_ID) diff --git a/tests/ui/UnreadIndicatorsTest.js b/tests/ui/UnreadIndicatorsTest.js index 6a64dda85b37..eadc94da6e37 100644 --- a/tests/ui/UnreadIndicatorsTest.js +++ b/tests/ui/UnreadIndicatorsTest.js @@ -369,6 +369,7 @@ describe('Unread Indicators', () => { // Tap the new report option and navigate back to the sidebar again via the back button return navigateToSidebarOption(0); }) + .then(waitForBatchedUpdates) .then(() => { // Verify that report we navigated to appears in a "read" state while the original unread report still shows as unread const hintText = Localize.translateLocal('accessibilityHints.chatUserDisplayNames'); diff --git a/tests/unit/DateUtilsTest.js b/tests/unit/DateUtilsTest.js index d8ea5e3b147a..d79fd3685948 100644 --- a/tests/unit/DateUtilsTest.js +++ b/tests/unit/DateUtilsTest.js @@ -1,5 +1,5 @@ import Onyx from 'react-native-onyx'; -import {format as tzFormat} from 'date-fns-tz'; +import {format as tzFormat, utcToZonedTime} from 'date-fns-tz'; import {addMinutes, subHours, subMinutes, subSeconds, format, setMinutes, setHours, subDays, addDays} from 'date-fns'; import CONST from '../../src/CONST'; import DateUtils from '../../src/libs/DateUtils'; @@ -130,6 +130,34 @@ describe('DateUtils', () => { expect(result).toBe(expectedDateTime); }); + describe('Date Comparison Functions', () => { + const today = new Date(); + const tomorrow = addDays(today, 1); + const yesterday = subDays(today, 1); + + const todayInTimezone = utcToZonedTime(today, timezone); + const tomorrowInTimezone = utcToZonedTime(tomorrow, timezone); + const yesterdayInTimezone = utcToZonedTime(yesterday, timezone); + + it('isToday should correctly identify today', () => { + expect(DateUtils.isToday(todayInTimezone, timezone)).toBe(true); + expect(DateUtils.isToday(tomorrowInTimezone, timezone)).toBe(false); + expect(DateUtils.isToday(yesterdayInTimezone, timezone)).toBe(false); + }); + + it('isTomorrow should correctly identify tomorrow', () => { + expect(DateUtils.isTomorrow(tomorrowInTimezone, timezone)).toBe(true); + expect(DateUtils.isTomorrow(todayInTimezone, timezone)).toBe(false); + expect(DateUtils.isTomorrow(yesterdayInTimezone, timezone)).toBe(false); + }); + + it('isYesterday should correctly identify yesterday', () => { + expect(DateUtils.isYesterday(yesterdayInTimezone, timezone)).toBe(true); + expect(DateUtils.isYesterday(todayInTimezone, timezone)).toBe(false); + expect(DateUtils.isYesterday(tomorrowInTimezone, timezone)).toBe(false); + }); + }); + describe('getDBTime', () => { it('should return the date in the format expected by the database', () => { const getDBTime = DateUtils.getDBTime();