diff --git a/android/app/build.gradle b/android/app/build.gradle index 806fd8d3efda..a53c6dfb62af 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -98,8 +98,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1001043505 - versionName "1.4.35-5" + versionCode 1001043600 + versionName "1.4.36-0" } flavorDimensions "default" diff --git a/contributingGuides/APPLE_GOOGLE_SIGNIN.md b/contributingGuides/APPLE_GOOGLE_SIGNIN.md index 43485a28b353..4bb86e31b486 100644 --- a/contributingGuides/APPLE_GOOGLE_SIGNIN.md +++ b/contributingGuides/APPLE_GOOGLE_SIGNIN.md @@ -259,12 +259,33 @@ if (CONFIG.ENVIRONMENT === CONST.ENVIRONMENT.DEV) { } ``` -#### Port requirements +#### Host/Port requirements Google allows the web app to be hosted at localhost, but according to the current Google console configuration for the Expensify client ID, it must be hosted on port 8082. +Also note that you'll need to update the webpack.dev.js config to change `host` from `dev.new.expensify.com` to `localhost` and server type from `https` to `http`. The reason for this is that Google Sign In allows localhost, but `dev.new.expensify.com` is not a registered Google Sign In domain. + +```diff +diff --git a/config/webpack/webpack.dev.js b/config/webpack/webpack.dev.js +index e28383eff5..b14f6f34aa 100644 +--- a/config/webpack/webpack.dev.js ++++ b/config/webpack/webpack.dev.js +@@ -44,9 +44,9 @@ module.exports = (env = {}) => + ...proxySettings, + historyApiFallback: true, + port, +- host: 'dev.new.expensify.com', ++ host: 'localhost', + server: { +- type: 'https', ++ type: 'http', + options: { + key: path.join(__dirname, 'key.pem'), + cert: path.join(__dirname, 'certificate.pem'), +``` + ### Desktop #### Set Environment to something other than "Development" diff --git a/docs/_includes/end-option.html b/docs/_includes/end-option.html new file mode 100644 index 000000000000..7f5eaa32ef17 --- /dev/null +++ b/docs/_includes/end-option.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docs/_includes/end-selector.html b/docs/_includes/end-selector.html new file mode 100644 index 000000000000..7f5eaa32ef17 --- /dev/null +++ b/docs/_includes/end-selector.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docs/_includes/option.html b/docs/_includes/option.html new file mode 100644 index 000000000000..0168c15dc97e --- /dev/null +++ b/docs/_includes/option.html @@ -0,0 +1 @@ +
\ No newline at end of file diff --git a/docs/_includes/selector.html b/docs/_includes/selector.html new file mode 100644 index 000000000000..be27578a519a --- /dev/null +++ b/docs/_includes/selector.html @@ -0,0 +1,9 @@ +{% assign values = include.values | split: "," %} + +
+ + diff --git a/docs/_layouts/default.html b/docs/_layouts/default.html index 7d98500ecf32..99f4b22b473c 100644 --- a/docs/_layouts/default.html +++ b/docs/_layouts/default.html @@ -12,6 +12,8 @@ + + @@ -64,10 +66,13 @@
{% if page.url contains "/articles/" %} -

- {{ page.name | remove: '.md' | split: "-" | join: " " }} -

- +
+

+ {{ page.name | remove: '.md' | split: "-" | join: " " }} +

+
+
+
{{ content }}
diff --git a/docs/_sass/_main.scss b/docs/_sass/_main.scss index e05f7d4c08ea..ea18acef7c23 100644 --- a/docs/_sass/_main.scss +++ b/docs/_sass/_main.scss @@ -350,10 +350,14 @@ button { h1 { &.title { font-size: 2.25em; + flex: 1; } } .article { + .hidden { + display: none; + } img { display: block; margin: 20px auto; @@ -459,6 +463,49 @@ button { } } + .selector-container { + background-color: $color-highlightBG; + display: flex; + flex-direction: row-reverse; + gap: 20px; + border-radius: 12px; + padding: 20px; + margin-bottom: 20px; + justify-content: space-between; + * > ol, ul { + padding: 0; + } + + @include maxBreakpoint($breakpoint-tablet) { + flex-direction: column; + } + } + + select { + height: 28px; + border-radius: 20px; + padding: 0px 26px 0px 12px; + color: $color-text; + font-size: 11px; + font-weight: 700; + text-align: center; + cursor: pointer; + + @include maxBreakpoint($breakpoint-tablet) { + width: 100px; + } + + } + + select { + background: url("/assets/images/down.svg") no-repeat right $color-button-background; + background-size: 12px; + background-position-x: 85%; + appearance: none !important; + -moz-appearance: none !important; + -webkit-appearance: none !important; + } + .info { padding: 12px; border-radius: 8px; @@ -862,3 +909,45 @@ button { } } } + +.title-platform-tabs { + display: flex; + justify-content: space-between; + padding-bottom: 12px; + h1 { + padding: 0; + } + + @include maxBreakpoint($breakpoint-tablet) { + flex-direction: column; + gap: 20px; + } +} + +#platform-tabs { + display: flex; + flex-wrap: wrap; + align-items: center; + text-align: center; + font-weight: 700; + font-size: 13px; + gap: 4px; +} + +#platform-tabs > * { + cursor: pointer; + border-radius: 20px; + padding: 10px 20px; + box-sizing: border-box; + height: 36px; + line-height: 16px; +} + +#platform-tabs > .active { + color: $color-text; + background-color: $color-button-background; +} + +.hidden { + display: none; +} diff --git a/docs/articles/expensify-classic/getting-started/Join-your-company's-workspace.md b/docs/articles/expensify-classic/getting-started/Join-your-company's-workspace.md new file mode 100644 index 000000000000..099f381e6010 --- /dev/null +++ b/docs/articles/expensify-classic/getting-started/Join-your-company's-workspace.md @@ -0,0 +1,258 @@ +--- +title: Join your company's workspace +description: Get started with Expensify as an employee or other company member +--- +
+ +# Overview + +Welcome to Expensify! If you received an invitation to join your company’s Expensify workspace, follow the steps below to get started. + +# 1. Download the mobile app + +Upload your expenses and check your reports right from your phone by downloading the Expensify mobile app. You can search for “Expensify” in the app store, or tap one of the links below. + +[iOS](https://apps.apple.com/us/app/expensify-expense-tracker/id471713959) +| [Android](https://play.google.com/store/apps/details?id=org.me.mobiexpensifyg&hl=en_US&gl=US) + +# 2. Add your name and photo + +{% include selector.html values="desktop, mobile" %} +{% include option.html value="desktop" %} +
    +
  1. Click the profile image at the top of the main menu.
  2. +
  3. Hover over the profile picture and click Change.
  4. +
  5. Update your profile picture and name. +
      +
    • Name: Enter your first and last name into the fields and click Update. Note that this name will be visible to anyone in your company workspace.
    • +
    • Photo: Click Add Photo.
    • +
    +
  6. +
+ +{% include end-option.html %} + +{% include option.html value="mobile" %} + +
    +
  1. Tap the ☰ menu icon in the top left.
  2. +
  3. Tap the profile picture icon.
  4. +
  5. Tap the Edit icon next to your name and update your name or photo. +
      +
    • Name: Enter your first and/or last name into the fields and tap Update. Note that this name will be visible to anyone in your company workspace.
    • +
    • Photo: Tap Upload Photo and either:
    • +
        +
      • Tap the capture button to take a new photo.
      • +
      • Tap the photo icon on the left to select a saved photo.
      • +
      +
    +
  6. +
+ +{% include end-option.html %} +{% include end-selector.html %} + + +# 3. Meet Concierge +Your personal assistant, Concierge, lives on your Expensify Home page on both desktop and the mobile app. + +Concierge will walk you through setting up your account and also provide: +
    +
  • Reminders to do things like submit your expenses
  • +
  • Alerts when more information is needed on an expense report
  • +
  • Updates on new and improved account features
  • +
+ +You can also get support at any time by clicking the green chat bubble in the right corner. This will open a chat with Concierge where you can ask questions and receive direct support. + +# 4. Learn how to add an expense +As an employee, you may need to document reimbursable expenses (like business travel paid for with personal funds) or non-reimbursable expenses (like a lunch paid for with a company card). You can create an expense automatically by SmartScanning a receipt, or you can enter them manually. + +## SmartScan a receipt + +You can upload pictures of your receipts to Expensify and SmartScan will automatically capture the receipt details including the merchant, date, total, and currency. + +{% include selector.html values="desktop, mobile" %} +{% include option.html value="desktop" %} +
    +
  1. Click the Expenses tab.
  2. +
  3. Click the + icon in the top right and select Scan Receipt.
  4. +
  5. Upload a saved image of a receipt.
  6. +
+ +{% include end-option.html %} + +{% include option.html value="mobile" %} +
    +
  1. Open the mobile app and tap the camera icon in the bottom right corner.
  2. +
  3. Upload or take a photo of your receipt.
  4. +
      +
    • Upload a photo: Click the photo icon in the left corner and select the image from your device.
    • +
    • Take a photo: Click the camera icon in the right corner to select the mode, make sure all of the transaction details are clearly visible, and then take the photo.
    • +
    +
  5. Normal Mode: Upload one receipt.
  6. +
  7. Rapid Fire Mode: Upload multiple receipts at once.
  8. +
+{% include end-option.html %} +{% include end-selector.html %} + +You can open any receipt and select **Fill out details myself** to add or edit the merchant, date, total, description, category, or add attendees who took part in the expense. You can also check that the expense is correctly labeled as reimbursable or non-reimbursable, and split the expense if multiple expenses are included on one receipt. + +*Note: You can also email receipts to SmartScan by sending them to receipts@expensify.com from an email address tied to your Expensify account (either a primary or secondary email). SmartScan will automatically pull all of the details from the receipt, fill them in for you, and add the receipt to the Expenses tab on your account.* + +## Manually enter an expense + +{% include selector.html values="desktop, mobile" %} + +{% include option.html value="desktop" %} +
    +
  1. Click the Expenses tab.
  2. +
  3. Click the + icon in the top right.
  4. +
  5. Select the type of expense and enter the expense details.
  6. +
      +
    • Manually create: Manually enter receipt details.
    • +
    • Scan receipt: Upload a saved image of a receipt.
    • +
    • Create multiple: Upload expenses in bulk.
    • +
    • Time: Create an expense based on hours.
    • +
    • Distance: Create an expense based on distance.
    • +
        +
      • Manually Create: Manually enter the distance details for the expense.
      • +
      • Create from Map: Enter the start and end destination and Expensify will help you create a receipt for the trip.
      • +
      +
    +
  7. Click Save.
  8. +
+{% include end-option.html %} + +{% include option.html value="mobile" %} +
    +
  1. Tap the ☰ menu icon in the top left.
  2. +
  3. Tap Expenses.
  4. +
  5. Tap the + icon in the top right.
  6. +
  7. Tap the correct expense type and enter the expense details.
  8. +
      +
    • Manually create: Manually enter receipt details.
    • +
    • Time: Enter work time and rate.
    • +
    • Manually create (Distance): Manually enter trip details by total distance.
    • +
    • Odometer: Manually enter trip details by start and end odometer readings.
    • +
    • Start GPS: Track distance while using the Expensify app to automatically calculate the distance in real time during the trip.
    • +
    +
  9. Tap Save.
  10. +
+{% include end-option.html %} + +{% include end-selector.html %} + +# 5. Learn how to create & submit an expense report + +Once you’ve created your expenses, they may be automatically added to an expense report if your company has this feature enabled. If not, your next step will be to add your expenses to a report and submit them for payment. + +{% include selector.html values="Desktop, Mobile" %} + +{% include option.html value="desktop" %} + +
    +
  1. Click the Reports tab.
  2. +
      +
    • If a report has been automatically created for your most recently submitted expense, then you don’t have to do anything else—your report is already created and will also be automatically submitted.
    • +
    • If a report has not been automatically created, follow the steps below.
    • +
    +
  3. Click New Report, or click the New Report dropdown and select Expense Report.
  4. +
  5. Click Add Expenses.
  6. +
  7. Click an expense to add it to the report.
  8. +
      +
    • If an expense you already added does not appear in the list, use the filter on the left to search by the merchant name or change the date range. Note: Only expenses that are not already on a report will appear.
    • +
    +
  9. Once all your expenses are added to the report, click the X to close the pop-up.
  10. +
  11. (Optional) Make any desired changes to the report and/or expenses.
  12. +
      +
    • Click the Edit icon next to the report name to change it. If this icon is not visible, the option has been disabled by your workspace.
    • +
    • Click the X icon next to an expense to remove it from the report.
    • +
    • Click the Expense Details icon to review or edit the expense details.
    • +
    • At the bottom of the report, add comments to include more information.
    • +
    • Click the Attachments icon to add additional attachments.
    • +
    +
  13. When the report is ready to send for approval, click Submit.
  14. +
  15. Enter the details for who will receive a notification email about your report and what they will receive.
  16. +
      +
    • To: Enter the name(s) who will be approving your report (if they are not already listed).
    • +
    • CC: Enter the email address of anyone else who should be notified that your expense report has been submitted. Add a comma between each email address if adding more than one.
    • +
    • Memo: Enter any relevant notes.
    • +
    • Attach PDF: Select this checkbox to attach a copy of your report to the email.
    • +
    +
  17. Click Send.
  18. +
+ +{% include end-option.html %} + +{% include option.html value="mobile" %} +
    +
  1. Tap the ☰ menu icon in the top left.
  2. +
  3. Tap Reports.
  4. +
      +
    • If a report has been automatically created for your most recently submitted expense, then you don’t have to do anything else—your report is already created and will also be automatically submitted.
    • +
    • If a report has not been automatically created, follow the steps below.
    • +
    +
  5. Tap the + icon and tap Expense Report.
  6. +
  7. Tap Add Expenses, then tap an expense to add it to the report. Repeat this step until all desired expenses are added. Note: Only expenses that are not already on a report will appear.
  8. +
  9. (Optional) Make any desired changes to the report and/or expenses.
  10. +
      +
    • Tap the report name to change it.
    • +
    • Tap an expense to review or edit the expense details.
    • +
    • At the bottom of the report, add comments to include more information.
    • +
    • Tap the Attachments icon to add additional attachments.
    • +
    +
  11. When the report is ready to send for approval, tap Submit Report.
  12. +
  13. Add any additional sending details and tap Submit.
  14. +
  15. Enter the details for who will receive a notification email about your report and what they will receive.
  16. +
      +
    • To: Enter the name(s) who will be approving your report (if they are not already listed).
    • +
    • CC: Enter the email address of anyone else who should be notified that your expense report has been submitted. Add a comma between each email address if adding more than one.
    • +
    • Memo: Enter any relevant notes.
    • +
    • Attach PDF: Select this checkbox to attach a copy of your report to the email.
    • +
    +
  17. Tap Submit.
  18. + +
+{% include end-option.html %} + +{% include end-selector.html %} + +# 6. Add a secondary login + +Connect your personal email address as a secondary login so you always have access to your Expensify account, even if your employer changes. + +*Note: This process is currently not available from the mobile app and must be completed from the Expensify website.* + +
    +
  1. Hover over Settings, then click Account.
  2. +
  3. Under the Account Details tab, scroll down to the Secondary Logins section and click Add Secondary Login.
  4. +
  5. Enter the email address or phone number you wish to use as a secondary login. For phone numbers, be sure to include the international code, if applicable.
  6. +
  7. Find the email or text message from Expensify containing the Magic Code and enter it into the field to add the secondary login.
  8. +
+ +# 7. Secure your account + +Add an extra layer of security to help keep your financial data safe and secure by enabling two-factor authentication. This will require you to enter a code generated by your preferred authenticator app (like Google Authenticator or Microsoft Authenticator) when you log in. + +*Note: This process is currently not available from the mobile app and must be completed from the Expensify website.* + +
    +
  1. Hover over Settings, then click Account.
  2. +
  3. Under the Account Details tab, scroll down to the Two Factor Authentication section and enable the toggle.
  4. +
  5. Save a copy of your backup codes. This step is critical—You will lose access to your account if you cannot use your authenticator app and do not have your recovery codes.
  6. +
      +
    • Click Download to save a copy of your backup codes to your computer.
    • +
    • Click Copy to paste the codes into a document or other secure location.
    • +
    +
  7. Click Continue.
  8. +
  9. Download or open your authenticator app and either:
  10. +
      +
    • Scan the QR code shown on your computer screen.
    • +
    • Enter the 6-digit code from your authenticator app into Expensify and click Verify.
    • +
    +
+ +When you log in to Expensify in the future, you’ll open your authenticator app to get the code and enter it into Expensify. A new code regenerates every few seconds, so the code is always different. If the code time runs out, you can generate a new code as needed. + +
\ No newline at end of file diff --git a/docs/articles/expensify-classic/getting-started/Security.md b/docs/articles/expensify-classic/getting-started/Security.md deleted file mode 100644 index 5a0036e3e161..000000000000 --- a/docs/articles/expensify-classic/getting-started/Security.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Security -description: Security ---- -## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/getting-started/Using-The-App.md b/docs/articles/expensify-classic/getting-started/Using-The-App.md index 9b8bc530e12e..f1bc31793ba8 100644 --- a/docs/articles/expensify-classic/getting-started/Using-The-App.md +++ b/docs/articles/expensify-classic/getting-started/Using-The-App.md @@ -2,6 +2,8 @@ title: Using the app description: Streamline expense management effortlessly with the Expensify mobile app. Learn how to install, enable push notifications, and use SmartScan to capture, categorize, and track expenses. Versatile for personal and business use, Expensify is a secure and automated solution for managing your finances on the go. --- + +
# Overview The Expensify mobile app is the ultimate expense management solution that makes it effortless to track and submit your receipts and expenses. Use the app to snap a picture of your receipts, categorize and submit expenses, and even review and approve expense reports. # How to install the Expensify app @@ -9,7 +11,7 @@ To get started with Expensify on your mobile device, you need to download the ap 1. Visit the App Store (iOS) or Google Play Store (Android). 2. Search for "Expensify" and select the official Expensify app. 3. Tap "Download" or "Install." - + Once the app is installed, open it and log in with your Expensify credentials. If you don't have an Expensify account, you can create one during the sign-up process. # How to enable on push notifications Push notifications keep you informed about expense approvals, reimbursements, and more. To enable push notifications: @@ -54,3 +56,10 @@ Expensify takes security seriously and employs encryption and other security mea Yes, you can use the mobile app offline to capture receipts and create expenses. The app will sync your data once you have an internet connection. {% include faq-end.md %} +
+
+ +# Coming soon + + +
\ No newline at end of file diff --git a/docs/assets/js/main.js b/docs/assets/js/main.js index 6e154bb09a44..6b3390148ff0 100644 --- a/docs/assets/js/main.js +++ b/docs/assets/js/main.js @@ -165,6 +165,37 @@ window.addEventListener('load', () => { insertElementAfter(searchInput, searchLabel); }); +const tocbotOptions = { + // Where to render the table of contents. + tocSelector: '.article-toc', + + // Where to grab the headings to build the table of contents. + contentSelector: '', + + // Disable the collapsible functionality of the library by + // setting the maximum number of heading levels (6) + collapseDepth: 6, + headingSelector: 'h1, h2, h3, summary', + + // Main class to add to lists. + listClass: 'lhn-items', + + // Main class to add to links. + linkClass: 'link', + + // Class to add to active links, + // the link corresponding to the top most heading on the page. + activeLinkClass: 'selected-article', + + // Headings offset between the headings and the top of the document (requires scrollSmooth enabled) + headingsOffset: 80, + scrollSmoothOffset: -80, + scrollSmooth: true, + + // If there is a fixed article scroll container, set to calculate titles' offset + scrollContainer: 'content-area', +}; + window.addEventListener('DOMContentLoaded', () => { injectFooterCopywrite(); @@ -179,38 +210,51 @@ window.addEventListener('DOMContentLoaded', () => { buttonCloseSidebar.addEventListener('click', closeSidebar); } - if (window.tocbot) { - window.tocbot.init({ - // Where to render the table of contents. - tocSelector: '.article-toc', + const expensifyClassicTab = document.getElementById('platform-tab-expensify-classic'); + const newExpensifyTab = document.getElementById('platform-tab-new-expensify'); - // Where to grab the headings to build the table of contents. - contentSelector: '.article-toc-content', + const expensifyClassicContent = document.getElementById('expensify-classic'); + const newExpensifyContent = document.getElementById('new-expensify'); - // Disable the collapsible functionality of the library by - // setting the maximum number of heading levels (6) - collapseDepth: 6, - headingSelector: 'h1, h2, h3, summary', + let contentSelector = '.article-toc-content'; + if (expensifyClassicContent) { + contentSelector = '#expensify-classic'; + } else if (newExpensifyContent) { + contentSelector = '#new-expensify'; + } - // Main class to add to lists. - listClass: 'lhn-items', + if (window.tocbot) { + window.tocbot.init({ + ...tocbotOptions, + contentSelector, + }); + } - // Main class to add to links. - linkClass: 'link', + // eslint-disable-next-line es/no-optional-chaining + expensifyClassicTab?.addEventListener('click', () => { + expensifyClassicTab.classList.add('active'); + expensifyClassicContent.classList.remove('hidden'); - // Class to add to active links, - // the link corresponding to the top most heading on the page. - activeLinkClass: 'selected-article', + newExpensifyTab.classList.remove('active'); + newExpensifyContent.classList.add('hidden'); + window.tocbot.refresh({ + ...tocbotOptions, + contentSelector: '#expensify-classic', + }); + }); - // Headings offset between the headings and the top of the document (requires scrollSmooth enabled) - headingsOffset: 80, - scrollSmoothOffset: -80, - scrollSmooth: true, + // eslint-disable-next-line es/no-optional-chaining + newExpensifyTab?.addEventListener('click', () => { + newExpensifyTab.classList.add('active'); + newExpensifyContent.classList.remove('hidden'); - // If there is a fixed article scroll container, set to calculate titles' offset - scrollContainer: 'content-area', + expensifyClassicTab.classList.remove('active'); + expensifyClassicContent.classList.add('hidden'); + window.tocbot.refresh({ + ...tocbotOptions, + contentSelector: '#new-expensify', }); - } + }); document.getElementById('header-button').addEventListener('click', toggleHeaderMenu); diff --git a/docs/assets/js/platform-tabs.js b/docs/assets/js/platform-tabs.js new file mode 100644 index 000000000000..e677e58b1e97 --- /dev/null +++ b/docs/assets/js/platform-tabs.js @@ -0,0 +1,22 @@ +const expensifyClassicContent = document.getElementById('expensify-classic'); +const newExpensifyContent = document.getElementById('new-expensify'); +const platformTabs = document.getElementById('platform-tabs'); + +if (expensifyClassicContent) { + const tab = document.createElement('div'); + tab.innerHTML = 'Expensify Classic'; + tab.id = 'platform-tab-expensify-classic'; + tab.classList.add('active'); + platformTabs.appendChild(tab); +} + +if (newExpensifyContent) { + const tab = document.createElement('div'); + tab.innerHTML = 'New Expensify'; + tab.id = 'platform-tab-new-expensify'; + + if (!expensifyClassicContent) { + tab.classList.add('active'); + } + platformTabs.appendChild(tab); +} diff --git a/docs/assets/js/selector.js b/docs/assets/js/selector.js new file mode 100644 index 000000000000..7373c7892767 --- /dev/null +++ b/docs/assets/js/selector.js @@ -0,0 +1,35 @@ +function syncSelectors(selectedIndex) { + const allSelects = document.querySelectorAll('select'); + for (let i = 0; i < allSelects.length; i++) { + allSelects[i].selectedIndex = selectedIndex; + } +} + +function selectOption(select) { + if (!select) { + return; + } + + syncSelectors(select.selectedIndex); + + const allOptions = Array.from(select.options); + const selectedValue = select.options[select.selectedIndex].value; + + // Hide section that isn't selected, and show section that is selected. + allOptions.forEach((option) => { + if (option.value === selectedValue) { + const toShow = document.getElementsByClassName(option.value); + for (let i = 0; i < toShow.length; i++) { + toShow[i].classList.remove('hidden'); + } + return; + } + + const toHide = document.getElementsByClassName(option.value); + for (let i = 0; i < toHide.length; i++) { + toHide[i].classList.add('hidden'); + } + }); +} + +window.onload = selectOption(document.getElementsByClassName('selector')[0]); diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index 5dc09b4564bb..6a6a54e58c49 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -19,7 +19,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.4.35 + 1.4.36 CFBundleSignature ???? CFBundleURLTypes @@ -40,7 +40,7 @@ CFBundleVersion - 1.4.35.5 + 1.4.36.0 ITSAppUsesNonExemptEncryption LSApplicationQueriesSchemes diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index 7dcdc2302ec4..763896fd45f1 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -15,10 +15,10 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 1.4.35 + 1.4.36 CFBundleSignature ???? CFBundleVersion - 1.4.35.5 + 1.4.36.0 diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index fca74ce93fc0..a4bef0847c95 100644 --- a/ios/NotificationServiceExtension/Info.plist +++ b/ios/NotificationServiceExtension/Info.plist @@ -11,9 +11,9 @@ CFBundleName $(PRODUCT_NAME) CFBundleShortVersionString - 1.4.35 + 1.4.36 CFBundleVersion - 1.4.35.5 + 1.4.36.0 NSExtension NSExtensionPointIdentifier diff --git a/package-lock.json b/package-lock.json index 57203662f350..674325508da5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "1.4.35-5", + "version": "1.4.36-0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "1.4.35-5", + "version": "1.4.36-0", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index a6971f881fd3..0c1105f978c6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "1.4.35-5", + "version": "1.4.36-0", "author": "Expensify, Inc.", "homepage": "https://new.expensify.com", "description": "New Expensify is the next generation of Expensify: a reimagination of payments based atop a foundation of chat.", diff --git a/patches/react-native-web+0.19.9+005+image-header-support.patch b/patches/react-native-web+0.19.9+005+image-header-support.patch deleted file mode 100644 index 4652e22662f0..000000000000 --- a/patches/react-native-web+0.19.9+005+image-header-support.patch +++ /dev/null @@ -1,200 +0,0 @@ -diff --git a/node_modules/react-native-web/dist/exports/Image/index.js b/node_modules/react-native-web/dist/exports/Image/index.js -index 95355d5..19109fc 100644 ---- a/node_modules/react-native-web/dist/exports/Image/index.js -+++ b/node_modules/react-native-web/dist/exports/Image/index.js -@@ -135,7 +135,22 @@ function resolveAssetUri(source) { - } - return uri; - } --var Image = /*#__PURE__*/React.forwardRef((props, ref) => { -+function raiseOnErrorEvent(uri, _ref) { -+ var onError = _ref.onError, -+ onLoadEnd = _ref.onLoadEnd; -+ if (onError) { -+ onError({ -+ nativeEvent: { -+ error: "Failed to load resource " + uri + " (404)" -+ } -+ }); -+ } -+ if (onLoadEnd) onLoadEnd(); -+} -+function hasSourceDiff(a, b) { -+ return a.uri !== b.uri || JSON.stringify(a.headers) !== JSON.stringify(b.headers); -+} -+var BaseImage = /*#__PURE__*/React.forwardRef((props, ref) => { - var ariaLabel = props['aria-label'], - blurRadius = props.blurRadius, - defaultSource = props.defaultSource, -@@ -236,16 +251,10 @@ var Image = /*#__PURE__*/React.forwardRef((props, ref) => { - } - }, function error() { - updateState(ERRORED); -- if (onError) { -- onError({ -- nativeEvent: { -- error: "Failed to load resource " + uri + " (404)" -- } -- }); -- } -- if (onLoadEnd) { -- onLoadEnd(); -- } -+ raiseOnErrorEvent(uri, { -+ onError, -+ onLoadEnd -+ }); - }); - } - function abortPendingRequest() { -@@ -277,10 +286,78 @@ var Image = /*#__PURE__*/React.forwardRef((props, ref) => { - suppressHydrationWarning: true - }), hiddenImage, createTintColorSVG(tintColor, filterRef.current)); - }); --Image.displayName = 'Image'; -+BaseImage.displayName = 'Image'; -+ -+/** -+ * This component handles specifically loading an image source with headers -+ * default source is never loaded using headers -+ */ -+var ImageWithHeaders = /*#__PURE__*/React.forwardRef((props, ref) => { -+ // $FlowIgnore: This component would only be rendered when `source` matches `ImageSource` -+ var nextSource = props.source; -+ var _React$useState3 = React.useState(''), -+ blobUri = _React$useState3[0], -+ setBlobUri = _React$useState3[1]; -+ var request = React.useRef({ -+ cancel: () => {}, -+ source: { -+ uri: '', -+ headers: {} -+ }, -+ promise: Promise.resolve('') -+ }); -+ var onError = props.onError, -+ onLoadStart = props.onLoadStart, -+ onLoadEnd = props.onLoadEnd; -+ React.useEffect(() => { -+ if (!hasSourceDiff(nextSource, request.current.source)) { -+ return; -+ } -+ -+ // When source changes we want to clean up any old/running requests -+ request.current.cancel(); -+ if (onLoadStart) { -+ onLoadStart(); -+ } -+ -+ // Store a ref for the current load request so we know what's the last loaded source, -+ // and so we can cancel it if a different source is passed through props -+ request.current = ImageLoader.loadWithHeaders(nextSource); -+ request.current.promise.then(uri => setBlobUri(uri)).catch(() => raiseOnErrorEvent(request.current.source.uri, { -+ onError, -+ onLoadEnd -+ })); -+ }, [nextSource, onLoadStart, onError, onLoadEnd]); -+ -+ // Cancel any request on unmount -+ React.useEffect(() => request.current.cancel, []); -+ var propsToPass = _objectSpread(_objectSpread({}, props), {}, { -+ // `onLoadStart` is called from the current component -+ // We skip passing it down to prevent BaseImage raising it a 2nd time -+ onLoadStart: undefined, -+ // Until the current component resolves the request (using headers) -+ // we skip forwarding the source so the base component doesn't attempt -+ // to load the original source -+ source: blobUri ? _objectSpread(_objectSpread({}, nextSource), {}, { -+ uri: blobUri -+ }) : undefined -+ }); -+ return /*#__PURE__*/React.createElement(BaseImage, _extends({ -+ ref: ref -+ }, propsToPass)); -+}); - - // $FlowIgnore: This is the correct type, but casting makes it unhappy since the variables aren't defined yet --var ImageWithStatics = Image; -+var ImageWithStatics = /*#__PURE__*/React.forwardRef((props, ref) => { -+ if (props.source && props.source.headers) { -+ return /*#__PURE__*/React.createElement(ImageWithHeaders, _extends({ -+ ref: ref -+ }, props)); -+ } -+ return /*#__PURE__*/React.createElement(BaseImage, _extends({ -+ ref: ref -+ }, props)); -+}); - ImageWithStatics.getSize = function (uri, success, failure) { - ImageLoader.getSize(uri, success, failure); - }; -diff --git a/node_modules/react-native-web/dist/modules/ImageLoader/index.js b/node_modules/react-native-web/dist/modules/ImageLoader/index.js -index bc06a87..e309394 100644 ---- a/node_modules/react-native-web/dist/modules/ImageLoader/index.js -+++ b/node_modules/react-native-web/dist/modules/ImageLoader/index.js -@@ -76,7 +76,7 @@ var ImageLoader = { - var image = requests["" + requestId]; - if (image) { - var naturalHeight = image.naturalHeight, -- naturalWidth = image.naturalWidth; -+ naturalWidth = image.naturalWidth; - if (naturalHeight && naturalWidth) { - success(naturalWidth, naturalHeight); - complete = true; -@@ -102,11 +102,19 @@ var ImageLoader = { - id += 1; - var image = new window.Image(); - image.onerror = onError; -- image.onload = e => { -+ image.onload = nativeEvent => { - // avoid blocking the main thread -- var onDecode = () => onLoad({ -- nativeEvent: e -- }); -+ var onDecode = () => { -+ // Append `source` to match RN's ImageLoadEvent interface -+ nativeEvent.source = { -+ uri: image.src, -+ width: image.naturalWidth, -+ height: image.naturalHeight -+ }; -+ onLoad({ -+ nativeEvent -+ }); -+ }; - if (typeof image.decode === 'function') { - // Safari currently throws exceptions when decoding svgs. - // We want to catch that error and allow the load handler -@@ -120,6 +128,32 @@ var ImageLoader = { - requests["" + id] = image; - return id; - }, -+ loadWithHeaders(source) { -+ var uri; -+ var abortController = new AbortController(); -+ var request = new Request(source.uri, { -+ headers: source.headers, -+ signal: abortController.signal -+ }); -+ request.headers.append('accept', 'image/*'); -+ var promise = fetch(request).then(response => response.blob()).then(blob => { -+ uri = URL.createObjectURL(blob); -+ return uri; -+ }).catch(error => { -+ if (error.name === 'AbortError') { -+ return ''; -+ } -+ throw error; -+ }); -+ return { -+ promise, -+ source, -+ cancel: () => { -+ abortController.abort(); -+ URL.revokeObjectURL(uri); -+ } -+ }; -+ }, - prefetch(uri) { - return new Promise((resolve, reject) => { - ImageLoader.load(uri, () => { diff --git a/src/CONST.ts b/src/CONST.ts index 69933a623bed..b27923465a1f 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -46,6 +46,7 @@ const CONST = { IN: 'in', OUT: 'out', }, + BACKGROUND_IMAGE_TRANSITION_DURATION: 1000, ARROW_HIDE_DELAY: 3000, API_ATTACHMENT_VALIDATIONS: { diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index db9d6a82e2da..83b6d1b31e4a 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -458,7 +458,7 @@ type OnyxValues = { [ONYXKEYS.COLLECTION.DOWNLOAD]: OnyxTypes.Download; [ONYXKEYS.COLLECTION.POLICY]: OnyxTypes.Policy; [ONYXKEYS.COLLECTION.POLICY_DRAFTS]: OnyxTypes.Policy; - [ONYXKEYS.COLLECTION.POLICY_CATEGORIES]: OnyxTypes.PolicyCategory; + [ONYXKEYS.COLLECTION.POLICY_CATEGORIES]: OnyxTypes.PolicyCategories; [ONYXKEYS.COLLECTION.POLICY_TAGS]: OnyxTypes.PolicyTags; [ONYXKEYS.COLLECTION.POLICY_MEMBERS]: OnyxTypes.PolicyMembers; [ONYXKEYS.COLLECTION.POLICY_MEMBERS_DRAFTS]: OnyxTypes.PolicyMember; @@ -479,6 +479,7 @@ type OnyxValues = { [ONYXKEYS.COLLECTION.SECURITY_GROUP]: OnyxTypes.SecurityGroup; [ONYXKEYS.COLLECTION.TRANSACTION]: OnyxTypes.Transaction; [ONYXKEYS.COLLECTION.TRANSACTION_DRAFT]: OnyxTypes.Transaction; + [ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS]: OnyxTypes.TransactionViolations; [ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_TAGS]: OnyxTypes.RecentlyUsedTags; [ONYXKEYS.COLLECTION.SELECTED_TAB]: string; [ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS]: OnyxTypes.TransactionViolation[]; @@ -492,8 +493,8 @@ type OnyxValues = { [ONYXKEYS.FORMS.WORKSPACE_SETTINGS_FORM_DRAFT]: OnyxTypes.WorkspaceSettingsForm; [ONYXKEYS.FORMS.WORKSPACE_RATE_AND_UNIT_FORM]: OnyxTypes.Form; [ONYXKEYS.FORMS.WORKSPACE_RATE_AND_UNIT_FORM_DRAFT]: OnyxTypes.Form; - [ONYXKEYS.FORMS.CLOSE_ACCOUNT_FORM]: OnyxTypes.Form; - [ONYXKEYS.FORMS.CLOSE_ACCOUNT_FORM_DRAFT]: OnyxTypes.Form; + [ONYXKEYS.FORMS.CLOSE_ACCOUNT_FORM]: OnyxTypes.CloseAccountForm; + [ONYXKEYS.FORMS.CLOSE_ACCOUNT_FORM_DRAFT]: OnyxTypes.CloseAccountForm; [ONYXKEYS.FORMS.PROFILE_SETTINGS_FORM]: OnyxTypes.Form; [ONYXKEYS.FORMS.PROFILE_SETTINGS_FORM_DRAFT]: OnyxTypes.Form; [ONYXKEYS.FORMS.DISPLAY_NAME_FORM]: OnyxTypes.DisplayNameForm; diff --git a/src/components/DotIndicatorMessage.tsx b/src/components/DotIndicatorMessage.tsx index d18704fdfb05..d2143f5b48da 100644 --- a/src/components/DotIndicatorMessage.tsx +++ b/src/components/DotIndicatorMessage.tsx @@ -8,13 +8,12 @@ import useThemeStyles from '@hooks/useThemeStyles'; import fileDownload from '@libs/fileDownload'; import * as Localize from '@libs/Localize'; import CONST from '@src/CONST'; +import type {ReceiptError} from '@src/types/onyx/Transaction'; import Icon from './Icon'; import * as Expensicons from './Icon/Expensicons'; import {PressableWithoutFeedback} from './Pressable'; import Text from './Text'; -type ReceiptError = {error?: string; source: string; filename: string}; - type DotIndicatorMessageProps = { /** * In most cases this should just be errors from onxyData @@ -23,7 +22,7 @@ type DotIndicatorMessageProps = { * timestamp: 'message', * } */ - messages: Record; + messages: Record; /** The type of message, 'error' shows a red dot, 'success' shows a green dot */ type: 'error' | 'success'; diff --git a/src/components/Image/BaseImage.native.tsx b/src/components/Image/BaseImage.native.tsx deleted file mode 100644 index c517efd04515..000000000000 --- a/src/components/Image/BaseImage.native.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import {Image as ExpoImage} from 'expo-image'; -import type {ImageProps as ExpoImageProps, ImageLoadEventData} from 'expo-image'; -import {useCallback} from 'react'; -import type {BaseImageProps} from './types'; - -function BaseImage({onLoad, ...props}: ExpoImageProps & BaseImageProps) { - const imageLoadedSuccessfully = useCallback( - (event: ImageLoadEventData) => { - if (!onLoad) { - return; - } - - // We override `onLoad`, so both web and native have the same signature - const {width, height} = event.source; - onLoad({nativeEvent: {width, height}}); - }, - [onLoad], - ); - - return ( - - ); -} - -BaseImage.displayName = 'BaseImage'; - -export default BaseImage; diff --git a/src/components/Image/BaseImage.tsx b/src/components/Image/BaseImage.tsx deleted file mode 100644 index ebdd76840267..000000000000 --- a/src/components/Image/BaseImage.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import React, {useCallback} from 'react'; -import {Image as RNImage} from 'react-native'; -import type {ImageLoadEventData, ImageProps as WebImageProps} from 'react-native'; -import type {BaseImageProps} from './types'; - -function BaseImage({onLoad, ...props}: WebImageProps & BaseImageProps) { - const imageLoadedSuccessfully = useCallback( - (event: {nativeEvent: ImageLoadEventData}) => { - if (!onLoad) { - return; - } - - // We override `onLoad`, so both web and native have the same signature - const {width, height} = event.nativeEvent.source; - onLoad({nativeEvent: {width, height}}); - }, - [onLoad], - ); - - return ( - - ); -} - -BaseImage.displayName = 'BaseImage'; - -export default BaseImage; diff --git a/src/components/Image/index.js b/src/components/Image/index.js index 8cee1cf95e14..ef1a69e19c12 100644 --- a/src/components/Image/index.js +++ b/src/components/Image/index.js @@ -1,35 +1,51 @@ import lodashGet from 'lodash/get'; -import React, {useMemo} from 'react'; +import React, {useEffect, useMemo} from 'react'; +import {Image as RNImage} from 'react-native'; import {withOnyx} from 'react-native-onyx'; -import CONST from '@src/CONST'; +import _ from 'underscore'; import ONYXKEYS from '@src/ONYXKEYS'; -import BaseImage from './BaseImage'; import {defaultProps, imagePropTypes} from './imagePropTypes'; import RESIZE_MODES from './resizeModes'; -function Image({source: propsSource, isAuthTokenRequired, session, ...forwardedProps}) { - // Update the source to include the auth token if required +function Image(props) { + const {source: propsSource, isAuthTokenRequired, onLoad, session} = props; + /** + * Check if the image source is a URL - if so the `encryptedAuthToken` is appended + * to the source. + */ const source = useMemo(() => { - if (typeof lodashGet(propsSource, 'uri') === 'number') { - return propsSource.uri; + if (isAuthTokenRequired) { + // There is currently a `react-native-web` bug preventing the authToken being passed + // in the headers of the image request so the authToken is added as a query param. + // On native the authToken IS passed in the image request headers + const authToken = lodashGet(session, 'encryptedAuthToken', null); + return {uri: `${propsSource.uri}?encryptedAuthToken=${encodeURIComponent(authToken)}`}; } - if (typeof propsSource !== 'number' && isAuthTokenRequired) { - const authToken = lodashGet(session, 'encryptedAuthToken'); - return { - ...propsSource, - headers: { - [CONST.CHAT_ATTACHMENT_TOKEN_KEY]: authToken, - }, - }; - } - return propsSource; // The session prop is not required, as it causes the image to reload whenever the session changes. For more information, please refer to issue #26034. // eslint-disable-next-line react-hooks/exhaustive-deps }, [propsSource, isAuthTokenRequired]); + /** + * The natural image dimensions are retrieved using the updated source + * and as a result the `onLoad` event needs to be manually invoked to return these dimensions + */ + useEffect(() => { + // If an onLoad callback was specified then manually call it and pass + // the natural image dimensions to match the native API + if (onLoad == null) { + return; + } + RNImage.getSize(source.uri, (width, height) => { + onLoad({nativeEvent: {width, height}}); + }); + }, [onLoad, source]); + + // Omit the props which the underlying RNImage won't use + const forwardedProps = _.omit(props, ['source', 'onLoad', 'session', 'isAuthTokenRequired']); + return ( - { + const {width, height, url} = evt.source; + dimensionsCache.set(url, {width, height}); + if (props.onLoad) { + props.onLoad({nativeEvent: {width, height}}); + } + }} + /> + ); +} + +Image.propTypes = imagePropTypes; +Image.defaultProps = defaultProps; +Image.displayName = 'Image'; +const ImageWithOnyx = withOnyx({ + session: { + key: ONYXKEYS.SESSION, + }, +})(Image); +ImageWithOnyx.resizeMode = RESIZE_MODES; +ImageWithOnyx.resolveDimensions = resolveDimensions; + +export default ImageWithOnyx; diff --git a/src/components/Image/types.ts b/src/components/Image/types.ts deleted file mode 100644 index 5a4c94364a46..000000000000 --- a/src/components/Image/types.ts +++ /dev/null @@ -1,8 +0,0 @@ -type BaseImageProps = { - /** Event called with image dimensions when image is loaded */ - onLoad?: (event: {nativeEvent: {width: number; height: number}}) => void; -}; - -export type {BaseImageProps}; - -export default BaseImageProps; diff --git a/src/components/LocaleContextProvider.tsx b/src/components/LocaleContextProvider.tsx index 7313bb4aa7bb..48e9aa49d0de 100644 --- a/src/components/LocaleContextProvider.tsx +++ b/src/components/LocaleContextProvider.tsx @@ -45,7 +45,7 @@ type LocaleContextProps = { /** 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: (phoneNumber: string) => string; + formatPhoneNumber: (phoneNumber: string | undefined) => string; /** Gets the locale digit corresponding to a standard digit */ toLocaleDigit: (digit: string) => string; diff --git a/src/components/MagicCodeInput.tsx b/src/components/MagicCodeInput.tsx index 4a6d87b48e38..9af07bef6af1 100644 --- a/src/components/MagicCodeInput.tsx +++ b/src/components/MagicCodeInput.tsx @@ -278,7 +278,7 @@ function MagicCodeInput( const indexToFocus = numbers[editIndex] === CONST.MAGIC_CODE_EMPTY_CHAR ? indexBeforeLastEditIndex : editIndex; const formElement = inputRefs.current as HTMLFormElement | null; - (formElement?.[indexToFocus] as HTMLInputElement).focus(); + (formElement?.[indexToFocus] as HTMLInputElement)?.focus(); onChangeTextProp(value.substring(0, indexToFocus)); return; diff --git a/src/components/MessagesRow.tsx b/src/components/MessagesRow.tsx index cfec6fd292e9..7c764ec94fcd 100644 --- a/src/components/MessagesRow.tsx +++ b/src/components/MessagesRow.tsx @@ -6,6 +6,7 @@ import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import type * as Localize from '@libs/Localize'; import CONST from '@src/CONST'; +import type {ReceiptError} from '@src/types/onyx/Transaction'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import DotIndicatorMessage from './DotIndicatorMessage'; import Icon from './Icon'; @@ -15,7 +16,7 @@ import Tooltip from './Tooltip'; type MessagesRowProps = { /** The messages to display */ - messages: Record; + messages: Record; /** The type of message, 'error' shows a red dot, 'success' shows a green dot */ type: 'error' | 'success'; diff --git a/src/components/OfflineWithFeedback.tsx b/src/components/OfflineWithFeedback.tsx index 1a8f313af267..2fad21fb54ef 100644 --- a/src/components/OfflineWithFeedback.tsx +++ b/src/components/OfflineWithFeedback.tsx @@ -8,6 +8,7 @@ import mapChildrenFlat from '@libs/mapChildrenFlat'; import shouldRenderOffscreen from '@libs/shouldRenderOffscreen'; import CONST from '@src/CONST'; import type * as OnyxCommon from '@src/types/onyx/OnyxCommon'; +import type {ReceiptError, ReceiptErrors} from '@src/types/onyx/Transaction'; import type ChildrenProps from '@src/types/utils/ChildrenProps'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import MessagesRow from './MessagesRow'; @@ -26,7 +27,7 @@ type OfflineWithFeedbackProps = ChildrenProps & { shouldHideOnDelete?: boolean; /** The errors to display */ - errors?: OnyxCommon.Errors | null; + errors?: OnyxCommon.Errors | ReceiptErrors | null; /** Whether we should show the error messages */ shouldShowErrorMessages?: boolean; @@ -84,7 +85,7 @@ function OfflineWithFeedback({ const hasErrors = !isEmptyObject(errors ?? {}); // Some errors have a null message. This is used to apply opacity only and to avoid showing redundant messages. - const errorMessages = omitBy(errors, (e) => e === null); + const errorMessages = omitBy(errors, (e: string | ReceiptError) => e === null); const hasErrorMessages = !isEmptyObject(errorMessages); const isOfflinePendingAction = !!isOffline && !!pendingAction; const isUpdateOrDeleteError = hasErrors && (pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE); diff --git a/src/components/OptionRow.tsx b/src/components/OptionRow.tsx index 3468193bf264..93c744225237 100644 --- a/src/components/OptionRow.tsx +++ b/src/components/OptionRow.tsx @@ -209,14 +209,14 @@ function OptionRow({ ) : ( ))} diff --git a/src/components/OptionsSelector/BaseOptionsSelector.js b/src/components/OptionsSelector/BaseOptionsSelector.js index 39c533bd2c3c..345680e809f3 100755 --- a/src/components/OptionsSelector/BaseOptionsSelector.js +++ b/src/components/OptionsSelector/BaseOptionsSelector.js @@ -41,6 +41,9 @@ const propTypes = { /** Whether referral CTA should be displayed */ shouldShowReferralCTA: PropTypes.bool, + /** A method triggered when the user closes the call to action banner */ + onCallToActionClosed: PropTypes.func, + /** Referral content type */ referralContentType: PropTypes.string, @@ -53,6 +56,7 @@ const propTypes = { const defaultProps = { shouldDelayFocus: false, shouldShowReferralCTA: false, + onCallToActionClosed: () => {}, referralContentType: CONST.REFERRAL_PROGRAM.CONTENT_TYPES.REFER_FRIEND, safeAreaPaddingBottomStyle: {}, contentContainerStyles: [], @@ -68,7 +72,7 @@ class BaseOptionsSelector extends Component { this.updateFocusedIndex = this.updateFocusedIndex.bind(this); this.scrollToIndex = this.scrollToIndex.bind(this); this.selectRow = this.selectRow.bind(this); - this.handleReferralModal = this.handleReferralModal.bind(this); + this.closeReferralModal = this.closeReferralModal.bind(this); this.selectFocusedOption = this.selectFocusedOption.bind(this); this.addToSelection = this.addToSelection.bind(this); this.updateSearchValue = this.updateSearchValue.bind(this); @@ -262,8 +266,9 @@ class BaseOptionsSelector extends Component { this.props.onChangeText(value); } - handleReferralModal() { + closeReferralModal() { this.setState((prevState) => ({shouldShowReferralModal: !prevState.shouldShowReferralModal})); + this.props.onCallToActionClosed(this.props.referralContentType); } handleFocusIn() { @@ -652,7 +657,7 @@ class BaseOptionsSelector extends Component { )} diff --git a/src/components/ReferralProgramCTA.tsx b/src/components/ReferralProgramCTA.tsx index 4a6b8b03f2b4..f1c7539cc6b5 100644 --- a/src/components/ReferralProgramCTA.tsx +++ b/src/components/ReferralProgramCTA.tsx @@ -9,6 +9,7 @@ import Icon from './Icon'; import {Close} from './Icon/Expensicons'; import {PressableWithoutFeedback} from './Pressable'; import Text from './Text'; +import Tooltip from './Tooltip'; type ReferralProgramCTAProps = { referralContentType: @@ -44,22 +45,24 @@ function ReferralProgramCTA({referralContentType, onCloseButtonPress = () => {}} {translate(`referralProgram.${referralContentType}.buttonText2`)} - { - e.preventDefault(); - }} - style={[styles.touchableButtonImage]} - role={CONST.ACCESSIBILITY_ROLE.BUTTON} - accessibilityLabel={translate('common.close')} - > - - + + { + e.preventDefault(); + }} + style={[styles.touchableButtonImage]} + role={CONST.ACCESSIBILITY_ROLE.BUTTON} + accessibilityLabel={translate('common.close')} + > + + + ); } diff --git a/src/components/ReportActionItem/MoneyRequestAction.js b/src/components/ReportActionItem/MoneyRequestAction.tsx similarity index 58% rename from src/components/ReportActionItem/MoneyRequestAction.js rename to src/components/ReportActionItem/MoneyRequestAction.tsx index 4fca8a0a1aea..ff29bf5b0ee8 100644 --- a/src/components/ReportActionItem/MoneyRequestAction.js +++ b/src/components/ReportActionItem/MoneyRequestAction.tsx @@ -1,83 +1,66 @@ -import lodashGet from 'lodash/get'; -import PropTypes from 'prop-types'; import React from 'react'; +import type {StyleProp, ViewStyle} from 'react-native'; import {withOnyx} from 'react-native-onyx'; -import _ from 'underscore'; -import networkPropTypes from '@components/networkPropTypes'; -import {withNetwork} from '@components/OnyxProvider'; -import refPropTypes from '@components/refPropTypes'; +import type {OnyxEntry} from 'react-native-onyx'; import RenderHTML from '@components/RenderHTML'; import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; import useThemeStyles from '@hooks/useThemeStyles'; -import compose from '@libs/compose'; import * as IOUUtils from '@libs/IOUUtils'; import Navigation from '@libs/Navigation/Navigation'; import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; import * as ReportUtils from '@libs/ReportUtils'; -import reportActionPropTypes from '@pages/home/report/reportActionPropTypes'; -import iouReportPropTypes from '@pages/iouReportPropTypes'; -import reportPropTypes from '@pages/reportPropTypes'; +import type {ContextMenuAnchor} from '@pages/home/report/ContextMenu/ReportActionContextMenu'; import * as Report from '@userActions/Report'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; +import type * as OnyxTypes from '@src/types/onyx'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; import MoneyRequestPreview from './MoneyRequestPreview'; -const propTypes = { +type MoneyRequestActionOnyxProps = { + /** Chat report associated with iouReport */ + chatReport: OnyxEntry; + + /** IOU report data object */ + iouReport: OnyxEntry; + + /** Report actions for this report */ + reportActions: OnyxEntry; +}; + +type MoneyRequestActionProps = MoneyRequestActionOnyxProps & { /** All the data of the action */ - action: PropTypes.shape(reportActionPropTypes).isRequired, + action: OnyxTypes.ReportAction; /** The ID of the associated chatReport */ - chatReportID: PropTypes.string.isRequired, + chatReportID: string; /** The ID of the associated request report */ - requestReportID: PropTypes.string.isRequired, + requestReportID: string; /** The ID of the current report */ - reportID: PropTypes.string.isRequired, + reportID: string; /** Is this IOUACTION the most recent? */ - isMostRecentIOUReportAction: PropTypes.bool.isRequired, + isMostRecentIOUReportAction: boolean; /** Popover context menu anchor, used for showing context menu */ - contextMenuAnchor: refPropTypes, + contextMenuAnchor?: ContextMenuAnchor; /** Callback for updating context menu active state, used for showing context menu */ - checkIfContextMenuActive: PropTypes.func, - - /* Onyx Props */ - /** chatReport associated with iouReport */ - chatReport: reportPropTypes, - - /** IOU report data object */ - iouReport: iouReportPropTypes, - - /** Array of report actions for this report */ - reportActions: PropTypes.objectOf(PropTypes.shape(reportActionPropTypes)), + checkIfContextMenuActive?: () => void; /** Whether the IOU is hovered so we can modify its style */ - isHovered: PropTypes.bool, - - network: networkPropTypes.isRequired, + isHovered?: boolean; /** Whether a message is a whisper */ - isWhisper: PropTypes.bool, + isWhisper?: boolean; /** Styles to be assigned to Container */ - // eslint-disable-next-line react/forbid-prop-types - style: PropTypes.arrayOf(PropTypes.object), -}; - -const defaultProps = { - contextMenuAnchor: undefined, - checkIfContextMenuActive: () => {}, - chatReport: {}, - iouReport: {}, - reportActions: {}, - isHovered: false, - style: [], - isWhisper: false, + style?: StyleProp; }; function MoneyRequestAction({ @@ -87,31 +70,32 @@ function MoneyRequestAction({ reportID, isMostRecentIOUReportAction, contextMenuAnchor, - checkIfContextMenuActive, + checkIfContextMenuActive = () => {}, chatReport, iouReport, reportActions, - isHovered, - network, + isHovered = false, style, - isWhisper, -}) { + isWhisper = false, +}: MoneyRequestActionProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); - const isSplitBillAction = lodashGet(action, 'originalMessage.type', '') === CONST.IOU.REPORT_ACTION_TYPE.SPLIT; + const {isOffline} = useNetwork(); + + const isSplitBillAction = action.actionName === CONST.REPORT.ACTIONS.TYPE.IOU && action.originalMessage.type === CONST.IOU.REPORT_ACTION_TYPE.SPLIT; const onMoneyRequestPreviewPressed = () => { if (isSplitBillAction) { - const reportActionID = lodashGet(action, 'reportActionID', '0'); + const reportActionID = action.reportActionID ?? '0'; Navigation.navigate(ROUTES.SPLIT_BILL_DETAILS.getRoute(chatReportID, reportActionID)); return; } // If the childReportID is not present, we need to create a new thread - const childReportID = lodashGet(action, 'childReportID', 0); + const childReportID = action?.childReportID ?? '0'; if (!childReportID) { const thread = ReportUtils.buildTransactionThread(action, requestReportID); - const userLogins = PersonalDetailsUtils.getLoginsByAccountIDs(thread.participantAccountIDs); + const userLogins = PersonalDetailsUtils.getLoginsByAccountIDs(thread.participantAccountIDs ?? []); Report.openReport(thread.reportID, userLogins, thread, action.reportActionID); Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(thread.reportID)); return; @@ -124,12 +108,12 @@ function MoneyRequestAction({ const isDeletedParentAction = ReportActionsUtils.isDeletedParentAction(action); const isReversedTransaction = ReportActionsUtils.isReversedTransaction(action); if ( - !_.isEmpty(iouReport) && - !_.isEmpty(reportActions) && - chatReport.iouReportID && + !isEmptyObject(iouReport) && + !isEmptyObject(reportActions) && + chatReport?.iouReportID && isMostRecentIOUReportAction && action.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD && - network.isOffline + isOffline ) { shouldShowPendingConversionMessage = IOUUtils.isIOUReportPendingCurrencyConversion(iouReport); } @@ -147,29 +131,24 @@ function MoneyRequestAction({ checkIfContextMenuActive={checkIfContextMenuActive} shouldShowPendingConversionMessage={shouldShowPendingConversionMessage} onPreviewPressed={onMoneyRequestPreviewPressed} - containerStyles={[styles.cursorPointer, isHovered ? styles.reportPreviewBoxHoverBorder : undefined, ...style]} + containerStyles={[styles.cursorPointer, isHovered ? styles.reportPreviewBoxHoverBorder : undefined, style]} isHovered={isHovered} isWhisper={isWhisper} /> ); } -MoneyRequestAction.propTypes = propTypes; -MoneyRequestAction.defaultProps = defaultProps; MoneyRequestAction.displayName = 'MoneyRequestAction'; -export default compose( - withOnyx({ - chatReport: { - key: ({chatReportID}) => `${ONYXKEYS.COLLECTION.REPORT}${chatReportID}`, - }, - iouReport: { - key: ({requestReportID}) => `${ONYXKEYS.COLLECTION.REPORT}${requestReportID}`, - }, - reportActions: { - key: ({chatReportID}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReportID}`, - canEvict: false, - }, - }), - withNetwork(), -)(MoneyRequestAction); +export default withOnyx({ + chatReport: { + key: ({chatReportID}) => `${ONYXKEYS.COLLECTION.REPORT}${chatReportID}`, + }, + iouReport: { + key: ({requestReportID}) => `${ONYXKEYS.COLLECTION.REPORT}${requestReportID}`, + }, + reportActions: { + key: ({chatReportID}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReportID}`, + canEvict: false, + }, +})(MoneyRequestAction); diff --git a/src/components/ReportActionItem/MoneyRequestPreview.js b/src/components/ReportActionItem/MoneyRequestPreview.tsx similarity index 62% rename from src/components/ReportActionItem/MoneyRequestPreview.js rename to src/components/ReportActionItem/MoneyRequestPreview.tsx index 7b38dcd34109..f321c63375d0 100644 --- a/src/components/ReportActionItem/MoneyRequestPreview.js +++ b/src/components/ReportActionItem/MoneyRequestPreview.tsx @@ -1,22 +1,20 @@ import ExpensiMark from 'expensify-common/lib/ExpensiMark'; import {truncate} from 'lodash'; -import lodashGet from 'lodash/get'; -import PropTypes from 'prop-types'; +import lodashSortBy from 'lodash/sortBy'; import React from 'react'; import {View} from 'react-native'; +import type {GestureResponderEvent, StyleProp, ViewStyle} from 'react-native'; import {withOnyx} from 'react-native-onyx'; -import _ from 'underscore'; +import type {OnyxEntry} from 'react-native-onyx'; import Icon from '@components/Icon'; import * as Expensicons from '@components/Icon/Expensicons'; import MoneyRequestSkeletonView from '@components/MoneyRequestSkeletonView'; import MultipleAvatars from '@components/MultipleAvatars'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import PressableWithFeedback from '@components/Pressable/PressableWithoutFeedback'; -import refPropTypes from '@components/refPropTypes'; import RenderHTML from '@components/RenderHTML'; import {showContextMenuForReport} from '@components/ShowContextMenuContext'; import Text from '@components/Text'; -import transactionPropTypes from '@components/transactionPropTypes'; import useLocalize from '@hooks/useLocalize'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; @@ -31,107 +29,99 @@ import * as ReceiptUtils from '@libs/ReceiptUtils'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; import * as ReportUtils from '@libs/ReportUtils'; import * as TransactionUtils from '@libs/TransactionUtils'; -import walletTermsPropTypes from '@pages/EnablePayments/walletTermsPropTypes'; -import reportActionPropTypes from '@pages/home/report/reportActionPropTypes'; -import iouReportPropTypes from '@pages/iouReportPropTypes'; -import reportPropTypes from '@pages/reportPropTypes'; +import type {ContextMenuAnchor} from '@pages/home/report/ContextMenu/ReportActionContextMenu'; import * as PaymentMethods from '@userActions/PaymentMethods'; import * as Report from '@userActions/Report'; import CONST from '@src/CONST'; import * as Localize from '@src/libs/Localize'; import ONYXKEYS from '@src/ONYXKEYS'; +import type * as OnyxTypes from '@src/types/onyx'; +import type {IOUMessage} from '@src/types/onyx/OriginalMessage'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; +import type {EmptyObject} from '@src/types/utils/EmptyObject'; import ReportActionItemImages from './ReportActionItemImages'; -const propTypes = { +type MoneyRequestPreviewOnyxProps = { + /** All of the personal details for everyone */ + personalDetails: OnyxEntry; + + /** Chat report associated with iouReport */ + chatReport: OnyxEntry; + + /** IOU report data object */ + iouReport: OnyxEntry; + + /** Session info for the currently logged in user. */ + session: OnyxEntry; + + /** The transaction attached to the action.message.iouTransactionID */ + transaction: OnyxEntry; + + /** Information about the user accepting the terms for payments */ + walletTerms: OnyxEntry; +}; + +type MoneyRequestPreviewProps = MoneyRequestPreviewOnyxProps & { /** The active IOUReport, used for Onyx subscription */ + // The iouReportID is used inside withOnyx HOC // eslint-disable-next-line react/no-unused-prop-types - iouReportID: PropTypes.string.isRequired, + iouReportID: string; /** The associated chatReport */ - chatReportID: PropTypes.string.isRequired, + chatReportID: string; /** The ID of the current report */ - reportID: PropTypes.string.isRequired, + reportID: string; /** Callback for the preview pressed */ - onPreviewPressed: PropTypes.func, + onPreviewPressed: (event?: GestureResponderEvent | KeyboardEvent) => void; /** All the data of the action, used for showing context menu */ - action: PropTypes.shape(reportActionPropTypes), + action: OnyxTypes.ReportAction; /** Popover context menu anchor, used for showing context menu */ - contextMenuAnchor: refPropTypes, + contextMenuAnchor?: ContextMenuAnchor; /** Callback for updating context menu active state, used for showing context menu */ - checkIfContextMenuActive: PropTypes.func, + checkIfContextMenuActive?: () => void; /** Extra styles to pass to View wrapper */ - // eslint-disable-next-line react/forbid-prop-types - containerStyles: PropTypes.arrayOf(PropTypes.object), - - /* Onyx Props */ - - /** chatReport associated with iouReport */ - chatReport: reportPropTypes, - - /** IOU report data object */ - iouReport: iouReportPropTypes, + containerStyles?: StyleProp; /** True if this is this IOU is a split instead of a 1:1 request */ - isBillSplit: PropTypes.bool.isRequired, + isBillSplit: boolean; /** True if the IOU Preview card is hovered */ - isHovered: PropTypes.bool, - - /** All of the personal details for everyone */ - personalDetails: PropTypes.objectOf( - PropTypes.shape({ - /** This is either the user's full name, or their login if full name is an empty string */ - displayName: PropTypes.string, - }), - ), - - /** The transaction attached to the action.message.iouTransactionID */ - transaction: transactionPropTypes, - - /** Session info for the currently logged in user. */ - session: PropTypes.shape({ - /** Currently logged in user email */ - email: PropTypes.string, - }), - - /** Information about the user accepting the terms for payments */ - walletTerms: walletTermsPropTypes, + isHovered?: boolean; /** Whether or not an IOU report contains money requests in a different currency * that are either created or cancelled offline, and thus haven't been converted to the report's currency yet */ - shouldShowPendingConversionMessage: PropTypes.bool, + shouldShowPendingConversionMessage?: boolean; /** Whether a message is a whisper */ - isWhisper: PropTypes.bool, + isWhisper?: boolean; }; -const defaultProps = { - iouReport: {}, - onPreviewPressed: null, - action: undefined, - contextMenuAnchor: undefined, - checkIfContextMenuActive: () => {}, - containerStyles: [], - walletTerms: {}, - chatReport: {}, - isHovered: false, - personalDetails: {}, - session: { - email: null, - }, - transaction: {}, - shouldShowPendingConversionMessage: false, - isWhisper: false, -}; - -function MoneyRequestPreview(props) { +function MoneyRequestPreview({ + iouReport, + isBillSplit, + session, + action, + personalDetails, + chatReport, + transaction, + contextMenuAnchor, + chatReportID, + reportID, + onPreviewPressed, + containerStyles, + walletTerms, + checkIfContextMenuActive = () => {}, + shouldShowPendingConversionMessage = false, + isHovered = false, + isWhisper = false, +}: MoneyRequestPreviewProps) { const theme = useTheme(); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); @@ -139,40 +129,40 @@ function MoneyRequestPreview(props) { const {isSmallScreenWidth, windowWidth} = useWindowDimensions(); const parser = new ExpensiMark(); - if (_.isEmpty(props.iouReport) && !props.isBillSplit) { + if (isEmptyObject(iouReport) && !isBillSplit) { return null; } - const sessionAccountID = lodashGet(props.session, 'accountID', null); - const managerID = props.iouReport.managerID || ''; - const ownerAccountID = props.iouReport.ownerAccountID || ''; - const isPolicyExpenseChat = ReportUtils.isPolicyExpenseChat(props.chatReport); + const sessionAccountID = session?.accountID; + const managerID = iouReport?.managerID ?? -1; + const ownerAccountID = iouReport?.ownerAccountID ?? -1; + const isPolicyExpenseChat = ReportUtils.isPolicyExpenseChat(chatReport); - const participantAccountIDs = props.isBillSplit ? lodashGet(props.action, 'originalMessage.participantAccountIDs', []) : [managerID, ownerAccountID]; - const participantAvatars = OptionsListUtils.getAvatarsForAccountIDs(participantAccountIDs, props.personalDetails); - const sortedParticipantAvatars = _.sortBy(participantAvatars, (avatar) => avatar.id); - if (isPolicyExpenseChat && props.isBillSplit) { - sortedParticipantAvatars.push(ReportUtils.getWorkspaceIcon(props.chatReport)); + const participantAccountIDs = action.actionName === CONST.REPORT.ACTIONS.TYPE.IOU && isBillSplit ? action.originalMessage.participantAccountIDs ?? [] : [managerID, ownerAccountID]; + const participantAvatars = OptionsListUtils.getAvatarsForAccountIDs(participantAccountIDs, personalDetails ?? {}); + const sortedParticipantAvatars = lodashSortBy(participantAvatars, (avatar) => avatar.id); + if (isPolicyExpenseChat && isBillSplit) { + sortedParticipantAvatars.push(ReportUtils.getWorkspaceIcon(chatReport)); } // Pay button should only be visible to the manager of the report. const isCurrentUserManager = managerID === sessionAccountID; - const {amount: requestAmount, currency: requestCurrency, comment: requestComment, merchant} = ReportUtils.getTransactionDetails(props.transaction); + const {amount: requestAmount, currency: requestCurrency, comment: requestComment, merchant} = ReportUtils.getTransactionDetails(transaction) ?? {}; const description = truncate(requestComment, {length: CONST.REQUEST_PREVIEW.MAX_LENGTH}); const requestMerchant = truncate(merchant, {length: CONST.REQUEST_PREVIEW.MAX_LENGTH}); - const hasReceipt = TransactionUtils.hasReceipt(props.transaction); - const isScanning = hasReceipt && TransactionUtils.isReceiptBeingScanned(props.transaction); - const hasFieldErrors = TransactionUtils.hasMissingSmartscanFields(props.transaction); - const isDistanceRequest = TransactionUtils.isDistanceRequest(props.transaction); - const isExpensifyCardTransaction = TransactionUtils.isExpensifyCardTransaction(props.transaction); - const isSettled = ReportUtils.isSettled(props.iouReport.reportID); - const isDeleted = lodashGet(props.action, 'pendingAction', null) === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; + const hasReceipt = TransactionUtils.hasReceipt(transaction); + const isScanning = hasReceipt && TransactionUtils.isReceiptBeingScanned(transaction); + const hasFieldErrors = TransactionUtils.hasMissingSmartscanFields(transaction); + const isDistanceRequest = TransactionUtils.isDistanceRequest(transaction); + const isCardTransaction = TransactionUtils.isCardTransaction(transaction); + const isSettled = ReportUtils.isSettled(iouReport?.reportID); + const isDeleted = action?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; // Show the merchant for IOUs and expenses only if they are custom or not related to scanning smartscan - const shouldShowMerchant = !_.isEmpty(requestMerchant) && requestMerchant !== CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT && requestMerchant !== CONST.TRANSACTION.DEFAULT_MERCHANT; - const shouldShowDescription = !_.isEmpty(description) && !shouldShowMerchant && !isScanning; - const hasPendingWaypoints = lodashGet(props.transaction, 'pendingFields.waypoints', null); + const shouldShowMerchant = !!requestMerchant && requestMerchant !== CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT && requestMerchant !== CONST.TRANSACTION.DEFAULT_MERCHANT; + const shouldShowDescription = !!description && !shouldShowMerchant && !isScanning; + const hasPendingWaypoints = transaction?.pendingFields?.waypoints; let merchantOrDescription = requestMerchant; if (!shouldShowMerchant) { @@ -181,20 +171,20 @@ function MoneyRequestPreview(props) { merchantOrDescription = requestMerchant.replace(CONST.REGEX.FIRST_SPACE, translate('common.tbd')); } - const receiptImages = hasReceipt ? [ReceiptUtils.getThumbnailAndImageURIs(props.transaction)] : []; + const receiptImages = hasReceipt ? [ReceiptUtils.getThumbnailAndImageURIs(transaction)] : []; - const getSettledMessage = () => { - if (isExpensifyCardTransaction) { + const getSettledMessage = (): string => { + if (isCardTransaction) { return translate('common.done'); } return translate('iou.settledExpensify'); }; - const showContextMenu = (event) => { - showContextMenuForReport(event, props.contextMenuAnchor, props.reportID, props.action, props.checkIfContextMenuActive); + const showContextMenu = (event: GestureResponderEvent) => { + showContextMenuForReport(event, contextMenuAnchor, reportID, action, checkIfContextMenuActive); }; - const getPreviewHeaderText = () => { + const getPreviewHeaderText = (): string => { if (isDistanceRequest) { return translate('common.distance'); } @@ -203,30 +193,30 @@ function MoneyRequestPreview(props) { return translate('common.receipt'); } - if (props.isBillSplit) { + if (isBillSplit) { return translate('iou.split'); } - if (isExpensifyCardTransaction) { + if (isCardTransaction) { let message = translate('iou.card'); - if (TransactionUtils.isPending(props.transaction)) { + if (TransactionUtils.isPending(transaction)) { message += ` • ${translate('iou.pending')}`; } return message; } let message = translate('iou.cash'); - if (ReportUtils.isPaidGroupPolicyExpenseReport(props.iouReport) && ReportUtils.isReportApproved(props.iouReport) && !ReportUtils.isSettled(props.iouReport)) { + if (ReportUtils.isPaidGroupPolicyExpenseReport(iouReport) && ReportUtils.isReportApproved(iouReport) && !ReportUtils.isSettled(iouReport?.reportID)) { message += ` • ${translate('iou.approved')}`; - } else if (props.iouReport.isWaitingOnBankAccount) { + } else if (iouReport?.isWaitingOnBankAccount) { message += ` • ${translate('iou.pending')}`; - } else if (props.iouReport.isCancelledIOU) { + } else if (iouReport?.isCancelledIOU) { message += ` • ${translate('iou.canceled')}`; } return message; }; - const getDisplayAmountText = () => { + const getDisplayAmountText = (): string => { if (isDistanceRequest) { return requestAmount && !hasPendingWaypoints ? CurrencyUtils.convertToDisplayString(requestAmount, requestCurrency) : translate('common.tbd'); } @@ -235,19 +225,16 @@ function MoneyRequestPreview(props) { return translate('iou.receiptScanning'); } - if (!isSettled && TransactionUtils.hasMissingSmartscanFields(props.transaction)) { + if (!isSettled && TransactionUtils.hasMissingSmartscanFields(transaction)) { return Localize.translateLocal('iou.receiptMissingDetails'); } return CurrencyUtils.convertToDisplayString(requestAmount, requestCurrency); }; - const getDisplayDeleteAmountText = () => { - const {amount, currency} = ReportUtils.getTransactionDetails(props.action.originalMessage); - - if (isDistanceRequest) { - return CurrencyUtils.convertToDisplayString(TransactionUtils.getAmount(props.action.originalMessage), currency); - } + const getDisplayDeleteAmountText = (): string => { + const iouOriginalMessage: IOUMessage | EmptyObject = action?.actionName === CONST.REPORT.ACTIONS.TYPE.IOU ? action.originalMessage : {}; + const {amount = 0, currency = CONST.CURRENCY.USD} = iouOriginalMessage; return CurrencyUtils.convertToDisplayString(amount, currency); }; @@ -257,36 +244,34 @@ function MoneyRequestPreview(props) { const childContainer = ( { PaymentMethods.clearWalletTermsError(); - Report.clearIOUError(props.chatReportID); + Report.clearIOUError(chatReportID); }} errorRowStyles={[styles.mbn1]} needsOffscreenAlphaCompositing > {hasReceipt && ( )} - {_.isEmpty(props.transaction) && - !ReportActionsUtils.isMessageDeleted(props.action) && - lodashGet(props.action, 'pendingAction') !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE ? ( + {isEmptyObject(transaction) && !ReportActionsUtils.isMessageDeleted(action) && action.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE ? ( ) : ( - {getPreviewHeaderText() + (isSettled && !props.iouReport.isCancelledIOU ? ` • ${getSettledMessage()}` : '')} + {getPreviewHeaderText() + (isSettled && !iouReport?.isCancelledIOU ? ` • ${getSettledMessage()}` : '')} {!isSettled && hasFieldErrors && ( {displayAmount} - {ReportUtils.isSettled(props.iouReport.reportID) && !props.isBillSplit && ( + {ReportUtils.isSettled(iouReport?.reportID) && !isBillSplit && ( )} - {props.isBillSplit && ( + {isBillSplit && ( @@ -331,17 +315,17 @@ function MoneyRequestPreview(props) { - {!isCurrentUserManager && props.shouldShowPendingConversionMessage && ( + {!isCurrentUserManager && shouldShowPendingConversionMessage && ( {translate('iou.pendingConversionMessage')} )} {shouldShowDescription && } {shouldShowMerchant && {merchantOrDescription}} - {props.isBillSplit && !_.isEmpty(participantAccountIDs) && requestAmount > 0 && ( + {isBillSplit && participantAccountIDs.length > 0 && requestAmount && requestAmount > 0 && ( {translate('iou.amountEach', { amount: CurrencyUtils.convertToDisplayString( - IOUUtils.calculateAmount(isPolicyExpenseChat ? 1 : participantAccountIDs.length - 1, requestAmount, requestCurrency), + IOUUtils.calculateAmount(isPolicyExpenseChat ? 1 : participantAccountIDs.length - 1, requestAmount, requestCurrency ?? ''), requestCurrency, ), })} @@ -355,32 +339,30 @@ function MoneyRequestPreview(props) { ); - if (!props.onPreviewPressed) { + if (!onPreviewPressed) { return childContainer; } - const shouldDisableOnPress = props.isBillSplit && _.isEmpty(props.transaction); + const shouldDisableOnPress = isBillSplit && isEmptyObject(transaction); return ( DeviceCapabilities.canUseTouchScreen() && ControlSelection.block()} onPressOut={() => ControlSelection.unblock()} onLongPress={showContextMenu} - accessibilityLabel={props.isBillSplit ? translate('iou.split') : translate('iou.cash')} + accessibilityLabel={isBillSplit ? translate('iou.split') : translate('iou.cash')} accessibilityHint={CurrencyUtils.convertToDisplayString(requestAmount, requestCurrency)} - style={[styles.moneyRequestPreviewBox, ...props.containerStyles, shouldDisableOnPress && styles.cursorDefault]} + style={[styles.moneyRequestPreviewBox, containerStyles, shouldDisableOnPress && styles.cursorDefault]} > {childContainer} ); } -MoneyRequestPreview.propTypes = propTypes; -MoneyRequestPreview.defaultProps = defaultProps; MoneyRequestPreview.displayName = 'MoneyRequestPreview'; -export default withOnyx({ +export default withOnyx({ personalDetails: { key: ONYXKEYS.PERSONAL_DETAILS_LIST, }, @@ -394,7 +376,10 @@ export default withOnyx({ key: ONYXKEYS.SESSION, }, transaction: { - key: ({action}) => `${ONYXKEYS.COLLECTION.TRANSACTION}${(action && action.originalMessage && action.originalMessage.IOUTransactionID) || 0}`, + key: ({action}) => { + const originalMessage = action?.actionName === CONST.REPORT.ACTIONS.TYPE.IOU ? action.originalMessage : undefined; + return `${ONYXKEYS.COLLECTION.TRANSACTION}${originalMessage?.IOUTransactionID ?? 0}`; + }, }, walletTerms: { key: ONYXKEYS.WALLET_TERMS, diff --git a/src/components/ReportActionItem/MoneyRequestView.js b/src/components/ReportActionItem/MoneyRequestView.tsx similarity index 71% rename from src/components/ReportActionItem/MoneyRequestView.js rename to src/components/ReportActionItem/MoneyRequestView.tsx index e03dd6e4744c..3533506797bb 100644 --- a/src/components/ReportActionItem/MoneyRequestView.js +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -1,30 +1,24 @@ -import lodashGet from 'lodash/get'; -import lodashValues from 'lodash/values'; -import PropTypes from 'prop-types'; import React, {useCallback} from 'react'; import {View} from 'react-native'; import {withOnyx} from 'react-native-onyx'; -import categoryPropTypes from '@components/categoryPropTypes'; +import type {OnyxEntry} from 'react-native-onyx'; import * as Expensicons from '@components/Icon/Expensicons'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import ReceiptEmptyState from '@components/ReceiptEmptyState'; import SpacerView from '@components/SpacerView'; import Switch from '@components/Switch'; -import tagPropTypes from '@components/tagPropTypes'; import Text from '@components/Text'; -import transactionPropTypes from '@components/transactionPropTypes'; import ViolationMessages from '@components/ViolationMessages'; -import withCurrentUserPersonalDetails, {withCurrentUserPersonalDetailsPropTypes} from '@components/withCurrentUserPersonalDetails'; import useLocalize from '@hooks/useLocalize'; import usePermissions from '@hooks/usePermissions'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import useViolations from '@hooks/useViolations'; +import type {ViolationField} from '@hooks/useViolations'; import useWindowDimensions from '@hooks/useWindowDimensions'; import * as CardUtils from '@libs/CardUtils'; -import compose from '@libs/compose'; import * as CurrencyUtils from '@libs/CurrencyUtils'; import Navigation from '@libs/Navigation/Navigation'; import * as OptionsListUtils from '@libs/OptionsListUtils'; @@ -34,97 +28,68 @@ import * as ReportActionsUtils from '@libs/ReportActionsUtils'; import * as ReportUtils from '@libs/ReportUtils'; import * as TransactionUtils from '@libs/TransactionUtils'; import AnimatedEmptyStateBackground from '@pages/home/report/AnimatedEmptyStateBackground'; -import reportActionPropTypes from '@pages/home/report/reportActionPropTypes'; -import iouReportPropTypes from '@pages/iouReportPropTypes'; -import reportPropTypes from '@pages/reportPropTypes'; -import {policyDefaultProps, policyPropTypes} from '@pages/workspace/withPolicy'; import * as IOU from '@userActions/IOU'; import * as Transaction from '@userActions/Transaction'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; +import type * as OnyxTypes from '@src/types/onyx'; +import type {TransactionPendingFieldsKey} from '@src/types/onyx/Transaction'; import ReportActionItemImage from './ReportActionItemImage'; -const violationNames = lodashValues(CONST.VIOLATIONS); +type MoneyRequestViewTransactionOnyxProps = { + /** The transaction associated with the transactionThread */ + transaction: OnyxEntry; -const transactionViolationPropType = PropTypes.shape({ - type: PropTypes.string.isRequired, - name: PropTypes.oneOf(violationNames).isRequired, - data: PropTypes.shape({ - rejectedBy: PropTypes.string, - rejectReason: PropTypes.string, - amount: PropTypes.string, - surcharge: PropTypes.number, - invoiceMarkup: PropTypes.number, - maxAge: PropTypes.number, - tagName: PropTypes.string, - formattedLimitAmount: PropTypes.string, - categoryLimit: PropTypes.string, - limit: PropTypes.string, - category: PropTypes.string, - brokenBankConnection: PropTypes.bool, - isAdmin: PropTypes.bool, - email: PropTypes.string, - isTransactionOlderThan7Days: PropTypes.bool, - member: PropTypes.string, - taxName: PropTypes.string, - }), -}); + /** Violations detected in this transaction */ + transactionViolations: OnyxEntry; +}; -const propTypes = { - /** The report currently being looked at */ - report: reportPropTypes.isRequired, +type MoneyRequestViewOnyxPropsWithoutTransaction = { + /** The policy object for the current route */ + policy: OnyxEntry; - /** Whether we should display the horizontal rule below the component */ - shouldShowHorizontalRule: PropTypes.bool.isRequired, + /** Collection of categories attached to a policy */ + policyCategories: OnyxEntry; + + /** Collection of tags attached to a policy */ + policyTags: OnyxEntry; - /* Onyx Props */ /** The expense report or iou report (only will have a value if this is a transaction thread) */ - parentReport: iouReportPropTypes, + parentReport: OnyxEntry; /** The actions from the parent report */ - parentReportActions: PropTypes.objectOf(PropTypes.shape(reportActionPropTypes)), - - /** The policy the report is tied to */ - ...policyPropTypes, - - /** Collection of categories attached to a policy */ - policyCategories: PropTypes.objectOf(categoryPropTypes), - - /** The transaction associated with the transactionThread */ - transaction: transactionPropTypes, - - /** Violations detected in this transaction */ - transactionViolations: PropTypes.arrayOf(transactionViolationPropType), + parentReportActions: OnyxEntry; +}; - /** Collection of tags attached to a policy */ - policyTags: tagPropTypes, +type MoneyRequestViewPropsWithoutTransaction = MoneyRequestViewOnyxPropsWithoutTransaction & { + /** The report currently being looked at */ + report: OnyxTypes.Report; - ...withCurrentUserPersonalDetailsPropTypes, + /** Whether we should display the horizontal rule below the component */ + shouldShowHorizontalRule: boolean; }; -const defaultProps = { - parentReport: {}, - parentReportActions: {}, - transaction: { - amount: 0, - currency: CONST.CURRENCY.USD, - comment: {comment: ''}, - }, - transactionViolations: [], - policyCategories: {}, - policyTags: {}, - ...policyDefaultProps, -}; +type MoneyRequestViewProps = MoneyRequestViewTransactionOnyxProps & MoneyRequestViewPropsWithoutTransaction; -function MoneyRequestView({report, parentReport, parentReportActions, policyCategories, shouldShowHorizontalRule, transaction, policyTags, policy, transactionViolations}) { +function MoneyRequestView({ + report, + parentReport, + parentReportActions, + policyCategories, + shouldShowHorizontalRule, + transaction, + policyTags, + policy, + transactionViolations, +}: MoneyRequestViewProps) { const theme = useTheme(); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const {isSmallScreenWidth} = useWindowDimensions(); const {translate} = useLocalize(); const {canUseViolations} = usePermissions(); - const parentReportAction = parentReportActions[report.parentReportActionID] || {}; + const parentReportAction = parentReportActions?.[report.parentReportActionID ?? ''] ?? null; const moneyRequestReport = parentReport; const { created: transactionDate, @@ -138,20 +103,20 @@ function MoneyRequestView({report, parentReport, parentReportActions, policyCate originalAmount: transactionOriginalAmount, originalCurrency: transactionOriginalCurrency, cardID: transactionCardID, - } = ReportUtils.getTransactionDetails(transaction); + } = ReportUtils.getTransactionDetails(transaction) ?? {}; const isEmptyMerchant = transactionMerchant === '' || transactionMerchant === CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT; const isDistanceRequest = TransactionUtils.isDistanceRequest(transaction); let formattedTransactionAmount = transactionAmount ? CurrencyUtils.convertToDisplayString(transactionAmount, transactionCurrency) : ''; - const hasPendingWaypoints = lodashGet(transaction, 'pendingFields.waypoints', null); + const hasPendingWaypoints = transaction?.pendingFields?.waypoints; if (isDistanceRequest && (!formattedTransactionAmount || hasPendingWaypoints)) { formattedTransactionAmount = translate('common.tbd'); } const formattedOriginalAmount = transactionOriginalAmount && transactionOriginalCurrency && CurrencyUtils.convertToDisplayString(transactionOriginalAmount, transactionOriginalCurrency); const isCardTransaction = TransactionUtils.isCardTransaction(transaction); - const cardProgramName = isCardTransaction ? CardUtils.getCardDescription(transactionCardID) : ''; + const cardProgramName = isCardTransaction && transactionCardID !== undefined ? CardUtils.getCardDescription(transactionCardID) : ''; // Flags for allowing or disallowing editing a money request - const isSettled = ReportUtils.isSettled(moneyRequestReport.reportID); + const isSettled = ReportUtils.isSettled(moneyRequestReport?.reportID); const isCancelled = moneyRequestReport && moneyRequestReport.isCancelledIOU; // Used for non-restricted fields such as: description, category, tag, billable, etc. @@ -168,26 +133,30 @@ function MoneyRequestView({report, parentReport, parentReportActions, policyCate // Fetches only the first tag, for now const policyTag = PolicyUtils.getTag(policyTags); - const policyTagsList = lodashGet(policyTag, 'tags', {}); + const policyTagsList = policyTag?.tags ?? {}; // Flags for showing categories and tags - const shouldShowCategory = isPolicyExpenseChat && (transactionCategory || OptionsListUtils.hasEnabledOptions(lodashValues(policyCategories))); - const shouldShowTag = isPolicyExpenseChat && (transactionTag || OptionsListUtils.hasEnabledOptions(lodashValues(policyTagsList))); - const shouldShowBillable = isPolicyExpenseChat && (transactionBillable || !lodashGet(policy, 'disabledFields.defaultBillable', true)); + // transactionCategory can be an empty string + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const shouldShowCategory = isPolicyExpenseChat && (transactionCategory || OptionsListUtils.hasEnabledOptions(Object.values(policyCategories ?? {}))); + // transactionTag can be an empty string + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const shouldShowTag = isPolicyExpenseChat && (transactionTag || OptionsListUtils.hasEnabledOptions(Object.values(policyTagsList))); + const shouldShowBillable = isPolicyExpenseChat && (!!transactionBillable || !(policy?.disabledFields?.defaultBillable ?? true)); - const {getViolationsForField} = useViolations(transactionViolations); - const hasViolations = useCallback((field) => canUseViolations && getViolationsForField(field).length > 0, [canUseViolations, getViolationsForField]); + const {getViolationsForField} = useViolations(transactionViolations ?? []); + const hasViolations = useCallback((field: ViolationField): boolean => !!canUseViolations && getViolationsForField(field).length > 0, [canUseViolations, getViolationsForField]); let amountDescription = `${translate('iou.amount')}`; const saveBillable = useCallback( - (newBillable) => { + (newBillable: boolean) => { // If the value hasn't changed, don't request to save changes on the server and just close the modal if (newBillable === TransactionUtils.getBillable(transaction)) { Navigation.dismissModal(); return; } - IOU.updateMoneyRequestBillable(transaction.transactionID, report.reportID, newBillable); + IOU.updateMoneyRequestBillable(transaction?.transactionID ?? '', report?.reportID, newBillable); Navigation.dismissModal(); }, [transaction, report], @@ -226,8 +195,8 @@ function MoneyRequestView({report, parentReport, parentReportActions, policyCate hasErrors = canEdit && TransactionUtils.hasMissingSmartscanFields(transaction); } - const pendingAction = lodashGet(transaction, 'pendingAction'); - const getPendingFieldAction = (fieldPath) => lodashGet(transaction, fieldPath) || pendingAction; + const pendingAction = transaction?.pendingAction; + const getPendingFieldAction = (fieldPath: TransactionPendingFieldsKey) => transaction?.pendingFields?.[fieldPath] ?? pendingAction; return ( @@ -236,17 +205,22 @@ function MoneyRequestView({report, parentReport, parentReportActions, policyCate {hasReceipt && ( { + if (!transaction?.transactionID) { + return; + } + Transaction.clearError(transaction.transactionID); }} > )} {canUseViolations && } - + Navigation.navigate(ROUTES.EDIT_REQUEST.getRoute(report.reportID, CONST.EDIT_REQUEST_FIELD.AMOUNT))} - brickRoadIndicator={hasViolations('amount') || (hasErrors && transactionAmount === 0) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : ''} + brickRoadIndicator={hasViolations('amount') || (hasErrors && transactionAmount === 0) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} error={hasErrors && transactionAmount === 0 ? translate('common.error.enterAmount') : ''} /> {canUseViolations && } - + Navigation.navigate(ROUTES.EDIT_REQUEST.getRoute(report.reportID, CONST.EDIT_REQUEST_FIELD.DESCRIPTION))} wrapperStyle={[styles.pv2, styles.taskDescriptionMenuItem]} - brickRoadIndicator={hasViolations('comment') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : ''} + brickRoadIndicator={hasViolations('comment') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} numberOfLinesTitle={0} /> {canUseViolations && } {isDistanceRequest ? ( - + ) : ( - + Navigation.navigate(ROUTES.EDIT_REQUEST.getRoute(report.reportID, CONST.EDIT_REQUEST_FIELD.MERCHANT))} - brickRoadIndicator={hasViolations('merchant') || (hasErrors && isEmptyMerchant && isPolicyExpenseChat) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : ''} + brickRoadIndicator={hasViolations('merchant') || (hasErrors && isEmptyMerchant && isPolicyExpenseChat) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} error={hasErrors && isPolicyExpenseChat && isEmptyMerchant ? translate('common.error.enterMerchant') : ''} /> {canUseViolations && } )} - + Navigation.navigate(ROUTES.EDIT_REQUEST.getRoute(report.reportID, CONST.EDIT_REQUEST_FIELD.DATE))} - brickRoadIndicator={hasViolations('date') || (hasErrors && transactionDate === '') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : ''} + brickRoadIndicator={hasViolations('date') || (hasErrors && transactionDate === '') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} error={hasErrors && transactionDate === '' ? translate('common.error.enterDate') : ''} /> {canUseViolations && } {shouldShowCategory && ( - + Navigation.navigate(ROUTES.EDIT_REQUEST.getRoute(report.reportID, CONST.EDIT_REQUEST_FIELD.CATEGORY))} - brickRoadIndicator={hasViolations('category') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : ''} + brickRoadIndicator={hasViolations('category') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} /> {canUseViolations && } )} {shouldShowTag && ( - + Navigation.navigate(ROUTES.EDIT_REQUEST.getRoute(report.reportID, CONST.EDIT_REQUEST_FIELD.TAG))} - brickRoadIndicator={hasViolations('tag') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : ''} + brickRoadIndicator={hasViolations('tag') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} /> {canUseViolations && } )} {isCardTransaction && ( - + {translate('common.billable')} @@ -405,50 +379,42 @@ function MoneyRequestView({report, parentReport, parentReportActions, policyCate ); } -MoneyRequestView.propTypes = propTypes; -MoneyRequestView.defaultProps = defaultProps; MoneyRequestView.displayName = 'MoneyRequestView'; -export default compose( - withCurrentUserPersonalDetails, - withOnyx({ - session: { - key: ONYXKEYS.SESSION, - }, - policy: { - key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY}${report.policyID}`, - }, - policyCategories: { - key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${report.policyID}`, - }, - policyTags: { - key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY_TAGS}${report.policyID}`, - }, - parentReport: { - key: ({report}) => `${ONYXKEYS.COLLECTION.REPORT}${report.parentReportID}`, - }, - parentReportActions: { - key: ({report}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report ? report.parentReportID : '0'}`, - canEvict: false, - }, - }), - withOnyx({ +export default withOnyx({ + policy: { + key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY}${report.policyID}`, + }, + policyCategories: { + key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${report.policyID}`, + }, + policyTags: { + key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY_TAGS}${report.policyID}`, + }, + parentReport: { + key: ({report}) => `${ONYXKEYS.COLLECTION.REPORT}${report.parentReportID}`, + }, + parentReportActions: { + key: ({report}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report ? report.parentReportID : '0'}`, + canEvict: false, + }, +})( + withOnyx({ transaction: { key: ({report, parentReportActions}) => { - const parentReportAction = lodashGet(parentReportActions, [report.parentReportActionID]); - const transactionID = lodashGet(parentReportAction, ['originalMessage', 'IOUTransactionID'], 0); + const parentReportAction = parentReportActions?.[report.parentReportActionID ?? '']; + const originalMessage = parentReportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.IOU ? parentReportAction.originalMessage : undefined; + const transactionID = originalMessage?.IOUTransactionID ?? 0; return `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`; }, }, transactionViolations: { key: ({report}) => { const parentReportAction = ReportActionsUtils.getParentReportAction(report); - const transactionID = lodashGet(parentReportAction, ['originalMessage', 'IOUTransactionID'], 0); + const originalMessage = parentReportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.IOU ? parentReportAction.originalMessage : undefined; + const transactionID = originalMessage?.IOUTransactionID ?? 0; return `${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`; }, }, - policyTags: { - key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY_TAGS}${report.policyID}`, - }, - }), -)(MoneyRequestView); + })(MoneyRequestView), +); diff --git a/src/components/ReportActionItem/ReportActionItemImage.tsx b/src/components/ReportActionItem/ReportActionItemImage.tsx index e71eb6834cc7..04a99e00c6bf 100644 --- a/src/components/ReportActionItem/ReportActionItemImage.tsx +++ b/src/components/ReportActionItem/ReportActionItemImage.tsx @@ -3,6 +3,7 @@ import React from 'react'; import type {ReactElement} from 'react'; import type {ImageSourcePropType, ViewStyle} from 'react-native'; import {View} from 'react-native'; +import type {OnyxEntry} from 'react-native-onyx'; import AttachmentModal from '@components/AttachmentModal'; import EReceiptThumbnail from '@components/EReceiptThumbnail'; import Image from '@components/Image'; @@ -21,19 +22,22 @@ type ReportActionItemImageProps = { thumbnail?: string | ImageSourcePropType | null; /** URI for the image or local numeric reference for the image */ - image: string | ImageSourcePropType; + image?: string | ImageSourcePropType; /** whether or not to enable the image preview modal */ enablePreviewModal?: boolean; /* The transaction associated with this image, if any. Passed for handling eReceipts. */ - transaction?: Transaction; + transaction?: OnyxEntry; /** whether thumbnail is refer the local file or not */ isLocalFile?: boolean; /** whether the receipt can be replaced */ canEditReceipt?: boolean; + + /** Filename of attachment */ + filename?: string; }; /** @@ -42,7 +46,7 @@ type ReportActionItemImageProps = { * and optional preview modal as well. */ -function ReportActionItemImage({thumbnail, image, enablePreviewModal = false, transaction, canEditReceipt = false, isLocalFile = false}: ReportActionItemImageProps) { +function ReportActionItemImage({thumbnail, image, enablePreviewModal = false, transaction, canEditReceipt = false, isLocalFile = false, filename}: ReportActionItemImageProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); const imageSource = tryResolveUrlFromApiRoot(image ?? ''); @@ -86,7 +90,7 @@ function ReportActionItemImage({thumbnail, image, enablePreviewModal = false, tr isReceiptAttachment canEditReceipt={canEditReceipt} allowDownload - originalFileName={transaction?.filename} + originalFileName={filename} > {({show}) => ( ; - - /** The policy of root parent report */ - rootParentReportpolicy: OnyxEntry; }; type TaskPreviewProps = WithCurrentUserPersonalDetailsProps & @@ -70,17 +62,7 @@ type TaskPreviewProps = WithCurrentUserPersonalDetailsProps & checkIfContextMenuActive: () => void; }; -function TaskPreview({ - taskReport, - taskReportID, - action, - contextMenuAnchor, - chatReportID, - checkIfContextMenuActive, - currentUserPersonalDetails, - rootParentReportpolicy, - isHovered = false, -}: TaskPreviewProps) { +function TaskPreview({taskReport, taskReportID, action, contextMenuAnchor, chatReportID, checkIfContextMenuActive, currentUserPersonalDetails, isHovered = false}: TaskPreviewProps) { const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const personalDetails = usePersonalDetails() || CONST.EMPTY_OBJECT; @@ -123,7 +105,7 @@ function TaskPreview({ style={[styles.mr2]} containerStyle={[styles.taskCheckbox]} isChecked={isTaskCompleted} - disabled={!Task.canModifyTask(taskReport, currentUserPersonalDetails.accountID, rootParentReportpolicy?.role)} + disabled={!Task.canModifyTask(taskReport, currentUserPersonalDetails.accountID)} onPress={Session.checkIfActionIsAllowed(() => { if (isTaskCompleted) { Task.reopenTask(taskReport); @@ -151,9 +133,5 @@ export default withCurrentUserPersonalDetails( taskReport: { key: ({taskReportID}) => `${ONYXKEYS.COLLECTION.REPORT}${taskReportID}`, }, - rootParentReportpolicy: { - key: ({policyID}) => `${ONYXKEYS.COLLECTION.POLICY}${policyID ?? '0'}`, - selector: (policy: Policy | null) => ({role: policy?.role}), - }, })(TaskPreview), ); diff --git a/src/components/ReportActionItem/TaskView.tsx b/src/components/ReportActionItem/TaskView.tsx index ea685d07579d..bdcc4d01b1a8 100644 --- a/src/components/ReportActionItem/TaskView.tsx +++ b/src/components/ReportActionItem/TaskView.tsx @@ -28,14 +28,11 @@ import * as Task from '@userActions/Task'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import type {PersonalDetailsList, Policy, Report} from '@src/types/onyx'; +import type {PersonalDetailsList, Report} from '@src/types/onyx'; type TaskViewOnyxProps = { /** All of the personal details for everyone */ personalDetails: OnyxEntry; - - /** The policy for the current route */ - policy: Pick | null; }; type TaskViewProps = TaskViewOnyxProps & @@ -47,7 +44,7 @@ type TaskViewProps = TaskViewOnyxProps & shouldShowHorizontalRule: boolean; }; -function TaskView({report, policy, shouldShowHorizontalRule, ...props}: TaskViewProps) { +function TaskView({report, shouldShowHorizontalRule, ...props}: TaskViewProps) { const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); useEffect(() => { @@ -61,7 +58,7 @@ function TaskView({report, policy, shouldShowHorizontalRule, ...props}: TaskView ); const isCompleted = ReportUtils.isCompletedTaskReport(report); const isOpen = ReportUtils.isOpenTaskReport(report); - const canModifyTask = Task.canModifyTask(report, props.currentUserPersonalDetails.accountID, policy?.role); + const canModifyTask = Task.canModifyTask(report, props.currentUserPersonalDetails.accountID); const disableState = !canModifyTask; const isDisableInteractive = !canModifyTask || !isOpen; const personalDetails = usePersonalDetails() || CONST.EMPTY_OBJECT; @@ -199,13 +196,6 @@ const TaskViewWithOnyx = withOnyx({ personalDetails: { key: ONYXKEYS.PERSONAL_DETAILS_LIST, }, - policy: { - key: ({report}) => { - const rootParentReport = ReportUtils.getRootParentReport(report); - return `${ONYXKEYS.COLLECTION.POLICY}${rootParentReport ? rootParentReport.policyID : '0'}`; - }, - selector: (policy: OnyxEntry) => (policy ? {role: policy.role} : null), - }, })(TaskView); export default withCurrentUserPersonalDetails(TaskViewWithOnyx); diff --git a/src/components/TaskHeaderActionButton.tsx b/src/components/TaskHeaderActionButton.tsx index d39f03c5aad4..2d964f58c253 100644 --- a/src/components/TaskHeaderActionButton.tsx +++ b/src/components/TaskHeaderActionButton.tsx @@ -14,9 +14,6 @@ import Button from './Button'; type TaskHeaderActionButtonOnyxProps = { /** Current user session */ session: OnyxEntry; - - /** The policy of root parent report */ - policy: OnyxEntry; }; type TaskHeaderActionButtonProps = TaskHeaderActionButtonOnyxProps & { @@ -24,7 +21,7 @@ type TaskHeaderActionButtonProps = TaskHeaderActionButtonOnyxProps & { report: OnyxTypes.Report; }; -function TaskHeaderActionButton({report, session, policy}: TaskHeaderActionButtonProps) { +function TaskHeaderActionButton({report, session}: TaskHeaderActionButtonProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); @@ -32,7 +29,7 @@ function TaskHeaderActionButton({report, session, policy}: TaskHeaderActionButto