diff --git a/.github/actions/javascript/authorChecklist/authorChecklist.ts b/.github/actions/javascript/authorChecklist/authorChecklist.ts index 8a57ee459230..6f5f39c79f3d 100644 --- a/.github/actions/javascript/authorChecklist/authorChecklist.ts +++ b/.github/actions/javascript/authorChecklist/authorChecklist.ts @@ -77,6 +77,7 @@ function checkPRForCompletedChecklist(expectedNumberOfChecklistItems: number, ch async function generateDynamicChecksAndCheckForCompletion() { // Generate dynamic checks + console.log('Generating dynamic checks...'); const dynamicChecks = await getChecklistCategoriesForPullRequest(); let isPassing = true; let didChecklistChange = false; @@ -91,6 +92,7 @@ async function generateDynamicChecksAndCheckForCompletion() { const regex = new RegExp(`- \\[([ x])] ${escapeRegExp(check)}`); const match = regex.exec(checklist); if (!match) { + console.log('Adding check to the checklist:', check); // Add it to the PR body isPassing = false; checklist += `- [ ] ${check}\r\n`; @@ -98,6 +100,7 @@ async function generateDynamicChecksAndCheckForCompletion() { } else { const isChecked = match[1] === 'x'; if (!isChecked) { + console.log('Found unchecked checklist item:', check); isPassing = false; } } @@ -112,6 +115,7 @@ async function generateDynamicChecksAndCheckForCompletion() { const match = regex.exec(checklist); if (match) { // Remove it from the PR body + console.log('Check has been removed from the checklist:', check); checklist = checklist.replace(match[0], ''); didChecklistChange = true; } @@ -123,6 +127,7 @@ async function generateDynamicChecksAndCheckForCompletion() { // Update the PR body if (didChecklistChange) { + console.log('Checklist changed, updating PR...'); await GithubUtils.octokit.pulls.update({ owner: CONST.GITHUB_OWNER, repo: CONST.APP_REPO, diff --git a/.github/actions/javascript/authorChecklist/index.js b/.github/actions/javascript/authorChecklist/index.js index f1102856c4b2..12b9bd3b648e 100644 --- a/.github/actions/javascript/authorChecklist/index.js +++ b/.github/actions/javascript/authorChecklist/index.js @@ -21574,6 +21574,7 @@ function checkPRForCompletedChecklist(expectedNumberOfChecklistItems, checklist) } async function generateDynamicChecksAndCheckForCompletion() { // Generate dynamic checks + console.log('Generating dynamic checks...'); const dynamicChecks = await getChecklistCategoriesForPullRequest(); let isPassing = true; let didChecklistChange = false; @@ -21585,6 +21586,7 @@ async function generateDynamicChecksAndCheckForCompletion() { const regex = new RegExp(`- \\[([ x])] ${(0, escapeRegExp_1.default)(check)}`); const match = regex.exec(checklist); if (!match) { + console.log('Adding check to the checklist:', check); // Add it to the PR body isPassing = false; checklist += `- [ ] ${check}\r\n`; @@ -21593,6 +21595,7 @@ async function generateDynamicChecksAndCheckForCompletion() { else { const isChecked = match[1] === 'x'; if (!isChecked) { + console.log('Found unchecked checklist item:', check); isPassing = false; } } @@ -21605,6 +21608,7 @@ async function generateDynamicChecksAndCheckForCompletion() { const match = regex.exec(checklist); if (match) { // Remove it from the PR body + console.log('Check has been removed from the checklist:', check); checklist = checklist.replace(match[0], ''); didChecklistChange = true; } @@ -21614,6 +21618,7 @@ async function generateDynamicChecksAndCheckForCompletion() { const newBody = contentBeforeChecklist + checklistStartsWith + checklist + checklistEndsWith + contentAfterChecklist; // Update the PR body if (didChecklistChange) { + console.log('Checklist changed, updating PR...'); await GithubUtils_1.default.octokit.pulls.update({ owner: CONST_1.default.GITHUB_OWNER, repo: CONST_1.default.APP_REPO, diff --git a/.github/workflows/authorChecklist.yml b/.github/workflows/authorChecklist.yml index 6234a2b73cf4..740e7b3a5e69 100644 --- a/.github/workflows/authorChecklist.yml +++ b/.github/workflows/authorChecklist.yml @@ -1,7 +1,9 @@ name: PR Author Checklist on: - pull_request: + # We use pull_request_target here so that the GitHub token will have read/write access and be able to modify the PR description if needed. + # Warning: – when using the pull_request_target event, DO NOT checkout code from an untrusted branch: https://securitylab.github.com/research/github-actions-preventing-pwn-requests/ + pull_request_target: types: [opened, edited, reopened, synchronize] jobs: @@ -16,4 +18,4 @@ jobs: - name: authorChecklist.js uses: ./.github/actions/javascript/authorChecklist with: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_TOKEN: ${{ github.token }} diff --git a/android/app/build.gradle b/android/app/build.gradle index d67b647c175c..e2d3e975ec0d 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -96,8 +96,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1001039803 - versionName "1.3.98-3" + versionCode 1001039805 + versionName "1.3.98-5" } flavorDimensions "default" diff --git a/docs/articles/expensify-classic/bank-accounts-and-credit-cards/Connect-ANZ.md b/docs/articles/expensify-classic/bank-accounts-and-credit-cards/Connect-ANZ.md deleted file mode 100644 index 59104ce36a41..000000000000 --- a/docs/articles/expensify-classic/bank-accounts-and-credit-cards/Connect-ANZ.md +++ /dev/null @@ -1,46 +0,0 @@ ---- -title: Connect ANZ -description: If you're an ANZ Visa customer, you can set up your cards to import transactions directly into Expensify and get a discount on your Expensify subscription. ---- - -# How to connect your ANZ Visa cards to Expensify -Importing your ANZ Visa into Expensify will allow your card transactions to flow into your Expensify account. These transactions will then merge automatically with any SmartScanned receipts in your Expensify reports. - -## To connect via ANZ Internet Banking - -1. Navigate to the ANZ Internet Banking portal. -2. Input your ANZ credentials to log into your online banking account. -3. Go to 'Your Settings' and click 'Manage bank feeds'. -4. Select 'Expensify'. -5. Fill out the Internet Banking data authority form and submit. -6. Once you’ve filled out and submitted your Internet Banking data authority form or ANZ Direct Online authority form, ANZ will set up the feed and send all the details directly to Expensify. -7. Then, we’ll add the card feed to your Expensify account and send you a message to let you know that it has been set up. We'll also include some webinar training resources to ensure you have all the information you need! - -## To connect via ANZ Direct Online - -1. Log into ANZ Direct Online with your ANZ credentials -2. Complete the ANZ Direct authority form by creating a batch and submitting the secure mail template form ‘Expensify Accounts Disclosure Authority’. -3. Detailed ANZ Direct Online instructions can be accessed here. -4. Once you’ve filled out and submitted your Internet Banking data authority form or ANZ Direct Online authority form, ANZ will set up the feed and send all the details directly to Expensify. -5. Then, we’ll add the card feed to your Expensify account and send you a message to let you know that it has been set up. We'll also include some webinar training resources to ensure you have all the information you need! - -# FAQ -## Are there discounts available for ANZ customers? - -As ANZ’s preferred receipt tracking and expense management partner, Expensify offers ANZ business customers a few special perks: -- 50% Expensify discount for the first 12 months. -- 15% Expensify discount after the first 12 months. - -This offer applies to all *new* ANZ New Zealand business customers. - -Sign up via www.use.expensify.com/anz to take advantage of this discount. - -If you’d like to get even more savings, you can do so by committing to an Annual subscription. More information on that on our pricing page. - -## What happens during the free trial? - -While on the free trial, you’ll be able to configure and use the full suite of Expensify features. In your Expensify account, you can see many days you have left on your free trial. Towards the end of the free trial, you’ll be prompted for a payment card. -After the free trial, you’ll get preferred pricing at 50% off the current rates for the first 12 months, which is automatically applied to your account. At the end of the initial 12 months, the ongoing discount of 15% off the current rates, will be applied automatically. - -## Do I need to sign up for a specific period in order to receive the discount? -There is no obligation to sign up for a certain period to receive the discount. After your free trial, the 50% discount for the first 12 months, will be applied automatically to your account. After the initial 12 months, the 15% discount will also be applied automatically. diff --git a/docs/articles/expensify-classic/bank-accounts-and-credit-cards/Personal-Credit-Cards.md b/docs/articles/expensify-classic/bank-accounts-and-credit-cards/Personal-Credit-Cards.md new file mode 100644 index 000000000000..c41178b4aa7f --- /dev/null +++ b/docs/articles/expensify-classic/bank-accounts-and-credit-cards/Personal-Credit-Cards.md @@ -0,0 +1,76 @@ +--- +title: Personal Credit Cards +description: How to import your personal credit cards into Expensify for tracking and reimbursement! +--- +# Overview +Welcome to the world of effortless expense tracking! Connecting your personal credit card for importing transactions is a breeze, and we're here to guide you through it. Let's dive in and get your financial journey started. + +## How to connect your personal card to import expenses +Importing your card or bank via Account Settings will: +Automatically sync your bank/card transactions with your Expensify account. These will merge seamlessly with any SmartScanned expenses in your account. +Generate IRS-compliant eReceipts, provided your Policy Admin has enabled this feature. +Discover below the numerous ways to easily bring your personal card expenses into Expensify below. + +### *Important terms to know:* +- _Transaction Start Date_: When you first import your card, you can choose a specific transaction start date. Depending on your bank, you can go back up to 90 days. If you need to include transactions from an earlier date, you can upload a spreadsheet of those transactions separately. +- _Deleted Cards_: If you delete an imported card from Expensify, all unsubmitted expenses associated with that card will also be removed from the system. Don't worry, though – any imported card transactions within a processing, approved, closed, or reimbursed report will remain unaffected. + +### *Importing expenses directly from your credit card or bank (in the web browser)* +1. Log into your Expensify account. +2. Navigate to *Settings* > *Account* > *Credit Card Import*. +3. Click the *Import Bank/Card* button. +4. Choose your bank from the list provided. If you don't see your bank, use the search box below to find it. If your bank isn't listed, you can use our CSV method to import your expenses instead. +5. Select a transaction start date using the calendar dropdown, then click *Take me there*. +6. Enter the same username and password you use for online banking to connect. +7. We'll display all the accounts linked to your username. Select the account(s) you want to import. +8. Once your card(s) are successfully imported, you can click *Update* next to the importing card(s) to fetch the latest transactions into your account. + +## Importing expenses directly from your credit card or bank (in the mobile app) +In your Settings, tap *Import a card* and follow the directions (similar to the instructions above) for your bank to start importing transactions. + +### Importing expenses via a spreadsheet +Spreadsheets can be uploaded as long as they are in one of the following formats: +- CSV +- XLS +- OFX +- QFX + +To import expenses via a spreadsheet: +1. Download a spreadsheet of your expenses directly from your online banking account. +_Please note: an OFX file type will require no editing but not all banks' OFX files are compatible, so CSV is a good fallback option._ +2. If you need to, delete any extra header rows so that you have one header row. At minimum, you should include the merchant, transaction date and amount. It's fine to have extra columns, but make sure you know which ones you’ll need so the data gets imported correctly. +3. Format the date column — if you use Excel, you can do this by navigating to *Format Cells* > *Custom*. You can choose your preferred format (e.g, yyyy-mm-dd or mm-dd-yyyy), but remember to use the same format each month. +4. Once your file is saved and ready, log into Expensify and click *Settings* > *Account* > *Credit Card Import*. Then click *Import Transactions from File* and on the next screen, select *Upload*. +5. Now, let's set up your file details. If this is the first time you're uploading for this card, keep the layout as *Default* and choose a logical *Mapping Name*, such as 'Platinum Visa.' You might use this layout again in the future so be sure to choose something that’s easy to remember. +6. Set the date format to match your CSV and adjust the currency to match your bank account currency. +7. If you've previously imported expenses for the same card, choose the default layout of a previously uploaded spreadsheet. +8. Scroll down and select which columns map to the merchant, date and amount (as a number without a currency symbol) – these are required presets which must be assigned. +9. If applicable, you can also map specific Categories and Tags as long as you don't have an integration connection to your default group policy. If you have an integration connected, you'll want to add the Categories and Tags to the expense after the expense is uploaded. +10. Check the preview of your selection under *Output Preview*. If everything looks good, you can then select *Add Expenses*. +11. For checking accounts, you may need to "Flip Amount Sign" as transactions are often exported as negative amounts. + +## Removing or managing settings for imported personal cards +*Changing How Card Appears*: If you'd like to change the name of how your imported card appears, click *Settings* located under the card listed. Here, you can rename the account as well as decide whether these transactions should be reimbursable by default. + +*Reimbursable Status of Imported Transactions*: +Expenses can be imported as either reimbursable or non-reimbursable. Select the option that best fits your card. +- *Reimbursable expenses* are expenses that are owed back to you by the person/company you are submitting them to. If you incurred an expense on a personal card you, it is likely reimbursable. +- *Non-reimbursable expenses* are expenses that are not owed back to you. This is likely spend incurred on a company card or any card that someone else pays the balance for that you incur spend on and must track the expenses for. + +*Remove a card*: If you need to remove a card, you can select the red trash can icon. Please remember this will remove all unreported and un-submitted transactions from your account that are tied to this card, so be careful! + +# FAQ +*Is the bank/credit card import option right for me?* +If you incur expenses using your personal or business card and need to get them accounted for in your company’s accounting, then you might want to import your bank/credit card. Please note, if you have a company-assigned corporate card, check with your company's Expensify admin on how to handle these cards. Often, admins will take care of card assignments, and you won't need to import them yourself. + +*Is the personal card spreadsheet import option right for me?* +Spreadsheet import is a great option for manually creating expenses in bulk when you run into any of the below scenarios. Take advantage of this time-saving option if: +- You have already imported your bank/credit card, but you need earlier transactions beyond the default 30-90 days (this varies depending on your bank). +- Your bank is not supported through a direct connection with Expensify, but you still need to get your transactions into the system. The best way to find if your bank is supported is to try importing it. If the bank is not listed, we do not support it at this time. +- You would like a way to do either of the above and would like to have the transactions merge with your SmartScanned receipts. + +*I'm not able to see the transactions I have imported when I click "View expenses". Why?* +If you aren't able to see the expenses imported when you click “View expenses”, try using the filters at the top of the Expenses screen to open up search criteria (such as the date) which often reveals "missing" expenses. + +*How do I remove an imported spreadsheet?* +If you need to remove an imported spreadsheet, you can select the red trash can icon. Please remember this will remove all unreported and unsubmitted transactions from your account that are tied to this import, so be careful! diff --git a/docs/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/Reconciliation.md b/docs/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/Reconciliation.md index b51329f2a803..d6de2ca66ade 100644 --- a/docs/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/Reconciliation.md +++ b/docs/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/Reconciliation.md @@ -1,5 +1,69 @@ --- -title: Reconciliation -description: Reconciliation +title: Company card reconciliation +description: How to reconcile company card transactions --- -## Resource Coming Soon! +# Overview + +If your company imports corporate card transactions into Expensify, you can reconcile them by using the Reconciliation dashboard under **Settings > Domains > Domain Name > Company Cards > Reconciliation**. To use the dashboard, simply enter the statement dates and click **Run**. + +# How to reconcile company cards + +## Confirm statement total + +To confirm the total of transactions imported into Expensify against a credit card statement: +1. Review the **Imported Total**, which shows the sum of all expenses imported into Expensify for that statement period. This should match the total on your credit card statement. +2. If there is a discrepancy, refresh the feed to import missing expenses. Click **Update all cards** for commercial card feeds, or update individual cards by clicking the blue cog icon and choosing **Update** for other feed types. +3. After updating, click **Run** to update the transaction totals. + +## Confirm card totals + +If there is a discrepancy between the totals on the credit card statement and the Reconciliation dashboard, then review each card’s total to find the source of the missing transactions. + +1. Sort the cards by clicking the heading for **Card Name/Number**, **Assignee** or **Total** and compare each card's total to the statement to determine which card(s) don't match the statement total. +2. Click on the **Total** amount for a card to view the imported expenses and identify any that are missing from the statement. Confirm that all cards have been assigned to cardholders, as this could be another reason that the Imported Total doesn't match the statement. +3. If there is still a discrepancy after updating and re-calculating the totals, please contact concierge@expensify.com and provide the details of the expenses that are showing on your statement but are missing in Expensify. To investigate, we’ll need the cardholder email, expense date, and amount. Keep in mind sorting by column heading also makes locating expenses easier. + +# Deep dive + +## Identifying outstanding unapproved expenses using the Reconciliation Dashboard +Use the **Unapproved total** and **Approved Total** on the Reconciliation Dashboard to identify expenses which have not yet been approved and/or exported: + +### View expenses +- Click on the **Unapproved Total** heading to sort cards by those with outstanding expenses. +- Click the **Unapproved** amount for a card to view the expenses which are in the Unreported, Open, Processing, or Deleted states. + +Note: You will need to be both a Domain Admin and a Workspace Admin to access expenses. + +### Add unreported and/or deleted expenses to a report + +- Change the filters so that only Unreported and/or Deleted expenses are showing. +- Select all expenses, then click **Add to a Report > Auto Report**. +- If there is an open report in the cardholder's account, the expense(s) will be added to that. If not, a new report will be created with these expenses. +## Process reports + +- Workspace admins have the ability to code (categorize, tag, comment or add a receipt) unsubmitted expenses, submit Open reports, and approve Processing reports. Any changes made by an admin are tracked under Report History and Comments at the bottom of each report. +- You can remind members to submit and approve reports via the Report History and Comments. An email notification will be sent to all members who have taken action on that report. + +## Prepare accrual + +If there are still unapproved expenses when you want to close your books for the month, then you can use the feed’s Imported, Approved, and Unapproved totals to create an accrual entry. +- Match Imported Total to Statement amount. +- Match Approved Total to Company Card Liability account in your accounting system. +- Unapproved Total becomes the Accrual amount (provided the first two amounts are correct). + +# FAQ + +## Who can view and access the Reconciliation tab? + +Domain admins can access the Reconciliation tool under **Settings > Domains > Company Cards > Reconciliation**. + +## Who can view and process company card transactions? + +Domain admins can view all company card transactions using the Reconciliation tool, even if they are unreported. Workspace admins can only view reported expenses on a workspace. So if a workspace admin does not have access to the domain, they will be unable to see any transaction that hasn’t been placed on a report. + +## What do I do if company card expenses are missing? + +If a cardholder reports expenses as missing, we first recommend using the Reconciliation tool to try and locate the expense. Select the date range the expense falls under, and once the report is available, select the specific card to view the data. If the expense is not listed, you will want to click **Update** next to the card under the Card List tab. This will pull in any missing expenses. + +If after updating, the expense still hasn’t appeared, you should reach out to Concierge with the missing expense specifics (merchant, date, amount and last four digits of the card number). Please note, only posted transactions will import. + diff --git a/docs/articles/expensify-classic/getting-started/Using-The-App.md b/docs/articles/expensify-classic/getting-started/Using-The-App.md new file mode 100644 index 000000000000..281a26a4317b --- /dev/null +++ b/docs/articles/expensify-classic/getting-started/Using-The-App.md @@ -0,0 +1,52 @@ +--- +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 +To get started with Expensify on your mobile device, you need to download the app: +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: +1. Open the Expensify app. +2. Go to "Settings" or "Preferences." +3. Find the "Receive realtime alerts" toggle +4. Toggle realtime alerts on to begin receiving notifications + +# Deep dive +## Using SmartScan on the App +### Capture receipts +1. Open the Expensify mobile app. +2. Tap the green camera button to take a photo of a receipt. +3. The receipt will be SmartScanned automatically. + +If you have multiple receipts tap the Rapid Fire Mode button in the bottom right hand corner to snap multiple pictures. You can also upload an existing photo from your gallery. +### SmartScan analysis +After capturing or uploading a receipt, Expensify's SmartScan technology goes to work. It analyzes the receipt to extract key details such as the merchant's name, transaction date, transaction currency, and total amount spent. SmartScan inputs all the data for you, so you don’t have to type a thing. +### Review and edit +Once SmartScan is finished, you can further categorize and code your expense based on your company’s policy. Review this data to ensure accuracy. If necessary, you can edit or add additional details, such as expense categories, tags, attendees, tax rates, or descriptions. +### Multi-Currency support +For businesses dealing with international expenses, SmartScan can handle multiple currencies and provide accurate exchange rate conversion based on your policies reporting currency. It's essential to set up and configure currency preferences for these scenarios. +### Custom expense categories +SmartScan can automatically categorize expenses based on vendor or merchant. Users can customize these categories to suit their specific accounting needs. This can be particularly useful for tracking expenses across different departments or projects. +### SmartScan outcomes +SmartScan's performance can vary depending on factors such as receipt quality, language, and handwriting. It's important to keep the following variables in mind: +**Receipt quality**: The clarity and condition of a receipt can impact SmartScan's accuracy. For best results, ensure your environment is well-lit and the receipt is straight and free of obstructions. +**Language support**: While SmartScan supports multiple languages, its accuracy may differ from one language to another. Users dealing with non-English receipts should be aware of potential variations in data extraction. +**Handwriting recognition**: Handwritten receipts might pose challenges for SmartScan. In such cases, manual verification may be necessary to ensure accurate data entry. +# FAQ +## Can I use the mobile app for both personal and business expenses? +Yes, you can use Expensify for personal and business expenses. It's versatile and suitable for both individual and corporate use. Check out our personal and business plans [here](https://www.expensify.com/pricing) to see what might be right for you. +## Is it possible to categorize and tag expenses on the mobile app? +Yes, you can categorize and tag expenses on the mobile app. The app allows you to customize categories and tags to help organize and track your spending. +## What should I do if I encounter issues with the mobile app, such as login problems or crashes? +If you experience issues, first make sure you’re using the most recent version of the app. You can also try to restarting the app. If the issue persists, you can start a chat with Concierge in the app or write to [concierge@expensify.com](mailto:concierge@expensify.com). +## Is the mobile app secure for managing sensitive financial information? +Expensify takes security seriously and employs encryption and other security measures to protect your data. It's important to use strong, unique passwords and enable device security features like biometric authentication. +## Can I use the mobile app offline, and will my data sync when I'm back online? +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. diff --git a/docs/assets/js/main.js b/docs/assets/js/main.js index aebd0f5d4864..bc816a7dd2cc 100644 --- a/docs/assets/js/main.js +++ b/docs/assets/js/main.js @@ -101,6 +101,7 @@ function closeSidebarOnClickOutside(event) { function openSidebar() { document.getElementById('sidebar-layer').style.display = 'block'; + document.getElementById('gsc-i-id1').focus(); // Make body unscrollable const yAxis = document.documentElement.style.getPropertyValue('y-axis'); @@ -108,6 +109,8 @@ function openSidebar() { body.style.position = 'fixed'; body.style.top = `-${yAxis}`; + document.getElementById('gsc-i-id1').focus(); + // Close the sidebar when clicking sidebar layer (outside the sidebar search) const sidebarLayer = document.getElementById('sidebar-layer'); if (sidebarLayer) { diff --git a/docs/context.xml b/docs/context.xml index f62520153883..f8ed64a38b51 100644 --- a/docs/context.xml +++ b/docs/context.xml @@ -18,7 +18,7 @@ - + diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index 6e399a33d3d6..0d91c1eaba10 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -40,7 +40,7 @@ CFBundleVersion - 1.3.98.3 + 1.3.98.5 ITSAppUsesNonExemptEncryption LSApplicationQueriesSchemes diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index cba8ff978ebc..be17155b8035 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -19,6 +19,6 @@ CFBundleSignature ???? CFBundleVersion - 1.3.98.3 + 1.3.98.5 diff --git a/package-lock.json b/package-lock.json index 79fee455925d..9cc966c185aa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "1.3.98-3", + "version": "1.3.98-5", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "1.3.98-3", + "version": "1.3.98-5", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index bcbbefe0b2d0..b0d6b874891e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "1.3.98-3", + "version": "1.3.98-5", "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/src/CONST.ts b/src/CONST.ts index 1a30b4d7058f..eda1dd34cc50 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -1366,6 +1366,10 @@ const CONST = { ILLEGAL_FILENAME_CHARACTERS: /\/|<|>|\*|"|:|\?|\\|\|/g, ENCODE_PERCENT_CHARACTER: /%(25)+/g, + + INVISIBLE_CHARACTERS_GROUPS: /[\p{C}\p{Z}]/gu, + + OTHER_INVISIBLE_CHARACTERS: /[\u3164]/g, }, PRONOUNS: { diff --git a/src/components/AttachmentModal.js b/src/components/AttachmentModal.js index cd438484583b..16a2adaacf4b 100755 --- a/src/components/AttachmentModal.js +++ b/src/components/AttachmentModal.js @@ -118,14 +118,14 @@ function AttachmentModal(props) { const [isAttachmentInvalid, setIsAttachmentInvalid] = useState(false); const [isDeleteReceiptConfirmModalVisible, setIsDeleteReceiptConfirmModalVisible] = useState(false); const [isAuthTokenRequired, setIsAuthTokenRequired] = useState(props.isAuthTokenRequired); - const [isAttachmentReceipt, setIsAttachmentReceipt] = useState(false); + const [isAttachmentReceipt, setIsAttachmentReceipt] = useState(null); const [attachmentInvalidReasonTitle, setAttachmentInvalidReasonTitle] = useState(''); const [attachmentInvalidReason, setAttachmentInvalidReason] = useState(null); const [source, setSource] = useState(props.source); const [modalType, setModalType] = useState(CONST.MODAL.MODAL_TYPE.CENTERED_UNSWIPEABLE); const [isConfirmButtonDisabled, setIsConfirmButtonDisabled] = useState(false); const [confirmButtonFadeAnimation] = useState(() => new Animated.Value(1)); - const [shouldShowDownloadButton, setShouldShowDownloadButton] = React.useState(true); + const [isDownloadButtonReadyToBeShown, setIsDownloadButtonReadyToBeShown] = React.useState(true); const {windowWidth} = useWindowDimensions(); const [file, setFile] = useState( @@ -174,13 +174,13 @@ function AttachmentModal(props) { ); const setDownloadButtonVisibility = useCallback( - (shouldShowButton) => { - if (shouldShowDownloadButton === shouldShowButton) { + (isButtonVisible) => { + if (isDownloadButtonReadyToBeShown === isButtonVisible) { return; } - setShouldShowDownloadButton(shouldShowButton); + setIsDownloadButtonReadyToBeShown(isButtonVisible); }, - [shouldShowDownloadButton], + [isDownloadButtonReadyToBeShown], ); /** @@ -394,6 +394,17 @@ function AttachmentModal(props) { // eslint-disable-next-line react-hooks/exhaustive-deps }, [isAttachmentReceipt, props.parentReport, props.parentReportActions, props.policy, props.transaction, file]); + // There are a few things that shouldn't be set until we absolutely know if the file is a receipt or an attachment. + // isAttachmentReceipt will be null until its certain what the file is, in which case it will then be true|false. + let headerTitle = props.headerTitle; + let shouldShowDownloadButton = false; + let shouldShowThreeDotsButton = false; + if (!_.isNull(isAttachmentReceipt)) { + headerTitle = translate(isAttachmentReceipt ? 'common.receipt' : 'common.attachment'); + shouldShowDownloadButton = props.allowDownload && isDownloadButtonReadyToBeShown && !isAttachmentReceipt && !isOffline; + shouldShowThreeDotsButton = isAttachmentReceipt && isModalOpen; + } + return ( <> {props.isSmallScreenWidth && } downloadAttachment(source)} shouldShowCloseButton={!props.isSmallScreenWidth} shouldShowBackButton={props.isSmallScreenWidth} onBackButtonPress={closeModal} onCloseButtonPress={closeModal} - shouldShowThreeDotsButton={isAttachmentReceipt && isModalOpen} + shouldShowThreeDotsButton={shouldShowThreeDotsButton} threeDotsAnchorPosition={styles.threeDotsPopoverOffsetAttachmentModal(windowWidth)} threeDotsMenuItems={threeDotsMenuItems} shouldOverlay diff --git a/src/components/Attachments/AttachmentCarousel/attachmentCarouselPropTypes.js b/src/components/Attachments/AttachmentCarousel/attachmentCarouselPropTypes.js index 8048773de42c..7543e8d9c099 100644 --- a/src/components/Attachments/AttachmentCarousel/attachmentCarouselPropTypes.js +++ b/src/components/Attachments/AttachmentCarousel/attachmentCarouselPropTypes.js @@ -1,4 +1,5 @@ import PropTypes from 'prop-types'; +import transactionPropTypes from '@components/transactionPropTypes'; import reportActionPropTypes from '@pages/home/report/reportActionPropTypes'; import reportPropTypes from '@pages/reportPropTypes'; @@ -16,15 +17,27 @@ const propTypes = { setDownloadButtonVisibility: PropTypes.func, /** Object of report actions for this report */ - reportActions: PropTypes.shape(reportActionPropTypes), + reportActions: PropTypes.objectOf(PropTypes.shape(reportActionPropTypes)), /** The report currently being looked at */ report: reportPropTypes.isRequired, + + /** The parent of `report` */ + parentReport: reportPropTypes, + + /** The report actions of the parent report */ + parentReportActions: PropTypes.objectOf(PropTypes.shape(reportActionPropTypes)), + + /** The transaction attached to the parent report action */ + transaction: transactionPropTypes, }; const defaultProps = { source: '', reportActions: {}, + parentReport: {}, + parentReportActions: {}, + transaction: {}, onNavigate: () => {}, onClose: () => {}, setDownloadButtonVisibility: () => {}, diff --git a/src/components/Attachments/AttachmentCarousel/extractAttachmentsFromReport.js b/src/components/Attachments/AttachmentCarousel/extractAttachmentsFromReport.js index 6f0dd335c2bb..28af6b511641 100644 --- a/src/components/Attachments/AttachmentCarousel/extractAttachmentsFromReport.js +++ b/src/components/Attachments/AttachmentCarousel/extractAttachmentsFromReport.js @@ -9,12 +9,13 @@ import CONST from '@src/CONST'; /** * Constructs the initial component state from report actions - * @param {Object} report - * @param {Array} reportActions + * @param {Object} parentReportAction + * @param {Object} reportActions + * @param {Object} transaction * @returns {Array} */ -function extractAttachmentsFromReport(report, reportActions) { - const actions = [ReportActionsUtils.getParentReportAction(report), ...ReportActionsUtils.getSortedReportActions(_.values(reportActions))]; +function extractAttachmentsFromReport(parentReportAction, reportActions, transaction) { + const actions = [parentReportAction, ...ReportActionsUtils.getSortedReportActions(_.values(reportActions))]; const attachments = []; const htmlParser = new HtmlParser({ @@ -51,7 +52,6 @@ function extractAttachmentsFromReport(report, reportActions) { return; } - const transaction = TransactionUtils.getTransaction(transactionID); if (TransactionUtils.hasReceipt(transaction)) { const {image} = ReceiptUtils.getThumbnailAndImageURIs(transaction); const isLocalFile = typeof image === 'string' && (image.startsWith('blob:') || image.startsWith('file:')); diff --git a/src/components/Attachments/AttachmentCarousel/index.js b/src/components/Attachments/AttachmentCarousel/index.js index 7d0500433377..c489f554edd4 100644 --- a/src/components/Attachments/AttachmentCarousel/index.js +++ b/src/components/Attachments/AttachmentCarousel/index.js @@ -1,3 +1,4 @@ +import lodashGet from 'lodash/get'; import React, {useCallback, useEffect, useRef, useState} from 'react'; import {FlatList, Keyboard, PixelRatio, View} from 'react-native'; import {withOnyx} from 'react-native-onyx'; @@ -9,7 +10,6 @@ import withWindowDimensions from '@components/withWindowDimensions'; import compose from '@libs/compose'; import * as DeviceCapabilities from '@libs/DeviceCapabilities'; import Navigation from '@libs/Navigation/Navigation'; -import * as ReportActionsUtils from '@libs/ReportActionsUtils'; import styles from '@styles/styles'; import variables from '@styles/variables'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -27,7 +27,7 @@ const viewabilityConfig = { itemVisiblePercentThreshold: 95, }; -function AttachmentCarousel({report, reportActions, source, onNavigate, setDownloadButtonVisibility, translate}) { +function AttachmentCarousel({report, reportActions, parentReportActions, source, onNavigate, setDownloadButtonVisibility, translate, transaction}) { const scrollRef = useRef(null); const canUseTouchScreen = DeviceCapabilities.canUseTouchScreen(); @@ -42,17 +42,16 @@ function AttachmentCarousel({report, reportActions, source, onNavigate, setDownl const compareImage = useCallback( (attachment) => { if (attachment.isReceipt && isReceipt) { - const action = ReportActionsUtils.getParentReportAction(report); - const transactionID = _.get(action, ['originalMessage', 'IOUTransactionID']); - return attachment.transactionID === transactionID; + return attachment.transactionID === transaction.transactionID; } return attachment.source === source; }, - [source, report, isReceipt], + [source, isReceipt, transaction], ); useEffect(() => { - const attachmentsFromReport = extractAttachmentsFromReport(report, reportActions); + const parentReportAction = parentReportActions[report.parentReportActionID]; + const attachmentsFromReport = extractAttachmentsFromReport(parentReportAction, reportActions, transaction); const initialPage = _.findIndex(attachmentsFromReport, compareImage); @@ -72,7 +71,7 @@ function AttachmentCarousel({report, reportActions, source, onNavigate, setDownl } } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [reportActions, compareImage]); + }, [reportActions, parentReportActions, compareImage]); /** * Updates the page state when the user navigates between attachments @@ -226,11 +225,28 @@ AttachmentCarousel.defaultProps = defaultProps; AttachmentCarousel.displayName = 'AttachmentCarousel'; export default compose( + // eslint-disable-next-line rulesdir/no-multiple-onyx-in-file withOnyx({ reportActions: { key: ({report}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`, canEvict: false, }, + parentReport: { + key: ({report}) => `${ONYXKEYS.COLLECTION.REPORT}${report ? report.parentReportID : '0'}`, + }, + parentReportActions: { + key: ({report}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report ? report.parentReportID : '0'}`, + canEvict: false, + }, + }), + // eslint-disable-next-line rulesdir/no-multiple-onyx-in-file + withOnyx({ + transaction: { + key: ({report, parentReportActions}) => { + const parentReportAction = lodashGet(parentReportActions, [report.parentReportActionID]); + return `${ONYXKEYS.COLLECTION.TRANSACTION}${lodashGet(parentReportAction, 'originalMessage.IOUTransactionID', 0)}`; + }, + }, }), withLocalize, withWindowDimensions, diff --git a/src/components/Attachments/AttachmentCarousel/index.native.js b/src/components/Attachments/AttachmentCarousel/index.native.js index d96a54b7a6b5..315702c2f56e 100644 --- a/src/components/Attachments/AttachmentCarousel/index.native.js +++ b/src/components/Attachments/AttachmentCarousel/index.native.js @@ -1,3 +1,4 @@ +import lodashGet from 'lodash/get'; import React, {useCallback, useEffect, useRef, useState} from 'react'; import {Keyboard, PixelRatio, View} from 'react-native'; import {withOnyx} from 'react-native-onyx'; @@ -7,7 +8,6 @@ import * as Illustrations from '@components/Icon/Illustrations'; import withLocalize from '@components/withLocalize'; import compose from '@libs/compose'; import Navigation from '@libs/Navigation/Navigation'; -import * as ReportActionsUtils from '@libs/ReportActionsUtils'; import styles from '@styles/styles'; import variables from '@styles/variables'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -18,7 +18,7 @@ import extractAttachmentsFromReport from './extractAttachmentsFromReport'; import AttachmentCarouselPager from './Pager'; import useCarouselArrows from './useCarouselArrows'; -function AttachmentCarousel({report, reportActions, source, onNavigate, onClose, setDownloadButtonVisibility, translate}) { +function AttachmentCarousel({report, reportActions, parentReportActions, source, onNavigate, setDownloadButtonVisibility, translate, transaction, onClose}) { const pagerRef = useRef(null); const [containerDimensions, setContainerDimensions] = useState({width: 0, height: 0}); @@ -32,17 +32,16 @@ function AttachmentCarousel({report, reportActions, source, onNavigate, onClose, const compareImage = useCallback( (attachment) => { if (attachment.isReceipt && isReceipt) { - const action = ReportActionsUtils.getParentReportAction(report); - const transactionID = _.get(action, ['originalMessage', 'IOUTransactionID']); - return attachment.transactionID === transactionID; + return attachment.transactionID === transaction.transactionID; } return attachment.source === source; }, - [source, report, isReceipt], + [source, isReceipt, transaction], ); useEffect(() => { - const attachmentsFromReport = extractAttachmentsFromReport(report, reportActions); + const parentReportAction = parentReportActions[report.parentReportActionID]; + const attachmentsFromReport = extractAttachmentsFromReport(parentReportAction, reportActions, transaction); const initialPage = _.findIndex(attachmentsFromReport, compareImage); @@ -173,11 +172,28 @@ AttachmentCarousel.defaultProps = defaultProps; AttachmentCarousel.displayName = 'AttachmentCarousel'; export default compose( + // eslint-disable-next-line rulesdir/no-multiple-onyx-in-file withOnyx({ reportActions: { key: ({report}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`, canEvict: false, }, + parentReport: { + key: ({report}) => `${ONYXKEYS.COLLECTION.REPORT}${report ? report.parentReportID : '0'}`, + }, + parentReportActions: { + key: ({report}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report ? report.parentReportID : '0'}`, + canEvict: false, + }, + }), + // eslint-disable-next-line rulesdir/no-multiple-onyx-in-file + withOnyx({ + transaction: { + key: ({report, parentReportActions}) => { + const parentReportAction = lodashGet(parentReportActions, [report.parentReportActionID]); + return `${ONYXKEYS.COLLECTION.TRANSACTION}${lodashGet(parentReportAction, 'originalMessage.IOUTransactionID', 0)}`; + }, + }, }), withLocalize, )(AttachmentCarousel); diff --git a/src/components/DisplayNames/DisplayNamesTooltipItem.js b/src/components/DisplayNames/DisplayNamesTooltipItem.tsx similarity index 53% rename from src/components/DisplayNames/DisplayNamesTooltipItem.js rename to src/components/DisplayNames/DisplayNamesTooltipItem.tsx index 09344f7f396b..8f215fefd71b 100644 --- a/src/components/DisplayNames/DisplayNamesTooltipItem.js +++ b/src/components/DisplayNames/DisplayNamesTooltipItem.tsx @@ -1,50 +1,45 @@ -import PropTypes from 'prop-types'; -import React, {useCallback} from 'react'; +import React, {RefObject, useCallback} from 'react'; +import {Text as RNText, StyleProp, TextStyle} from 'react-native'; import Text from '@components/Text'; import UserDetailsTooltip from '@components/UserDetailsTooltip'; +import {AvatarSource} from '@libs/UserUtils'; import styles from '@styles/styles'; -const propTypes = { - index: PropTypes.number, +type DisplayNamesTooltipItemProps = { + index?: number; /** The full title of the DisplayNames component (not split up) */ - getTooltipShiftX: PropTypes.func, + getTooltipShiftX?: (index: number) => number | undefined; /** The Account ID for the tooltip */ - accountID: PropTypes.number, + accountID?: number; /** The name to display in bold */ - displayName: PropTypes.string, + displayName?: string; /** The login for the tooltip fallback */ - login: PropTypes.string, + login?: string; /** The avatar for the tooltip fallback */ - avatar: PropTypes.oneOfType([PropTypes.string, PropTypes.func, PropTypes.node]), + avatar?: AvatarSource; /** Arbitrary styles of the displayName text */ - // eslint-disable-next-line react/forbid-prop-types - textStyles: PropTypes.arrayOf(PropTypes.object), + textStyles?: StyleProp; /** Refs to all the names which will be used to correct the horizontal position of the tooltip */ - childRefs: PropTypes.shape({ - // eslint-disable-next-line react/forbid-prop-types - current: PropTypes.arrayOf(PropTypes.object), - }), + childRefs: RefObject; }; -const defaultProps = { - index: 0, - getTooltipShiftX: () => {}, - accountID: 0, - displayName: '', - login: '', - avatar: '', - textStyles: [], - childRefs: {current: []}, -}; - -function DisplayNamesTooltipItem({index, getTooltipShiftX, accountID, avatar, login, displayName, textStyles, childRefs}) { +function DisplayNamesTooltipItem({ + index = 0, + getTooltipShiftX = () => undefined, + accountID = 0, + avatar = '', + login = '', + displayName = '', + textStyles = [], + childRefs = {current: []}, +}: DisplayNamesTooltipItemProps) { const tooltipIndexBridge = useCallback(() => getTooltipShiftX(index), [getTooltipShiftX, index]); return ( @@ -62,9 +57,14 @@ function DisplayNamesTooltipItem({index, getTooltipShiftX, accountID, avatar, lo (childRefs.current[index] = el)} - style={[...textStyles, styles.pre]} + ref={(el) => { + if (!childRefs.current?.[index] || !el) { + return; + } + // eslint-disable-next-line no-param-reassign + childRefs.current[index] = el; + }} + style={[textStyles, styles.pre]} > {displayName} @@ -72,8 +72,6 @@ function DisplayNamesTooltipItem({index, getTooltipShiftX, accountID, avatar, lo ); } -DisplayNamesTooltipItem.propTypes = propTypes; -DisplayNamesTooltipItem.defaultProps = defaultProps; DisplayNamesTooltipItem.displayName = 'DisplayNamesTooltipItem'; export default DisplayNamesTooltipItem; diff --git a/src/components/DisplayNames/DisplayNamesWithTooltip.js b/src/components/DisplayNames/DisplayNamesWithTooltip.tsx similarity index 68% rename from src/components/DisplayNames/DisplayNamesWithTooltip.js rename to src/components/DisplayNames/DisplayNamesWithTooltip.tsx index 254ad3ff824f..43cfab23d46f 100644 --- a/src/components/DisplayNames/DisplayNamesWithTooltip.js +++ b/src/components/DisplayNames/DisplayNamesWithTooltip.tsx @@ -1,17 +1,17 @@ -import lodashGet from 'lodash/get'; import React, {Fragment, useCallback, useRef} from 'react'; -import {View} from 'react-native'; -import _ from 'underscore'; +import {Text as RNText, View} from 'react-native'; import Text from '@components/Text'; import Tooltip from '@components/Tooltip'; import styles from '@styles/styles'; -import {defaultProps, propTypes} from './displayNamesPropTypes'; import DisplayNamesTooltipItem from './DisplayNamesTooltipItem'; +import DisplayNamesProps from './types'; -function DisplayNamesWithToolTip(props) { - const containerRef = useRef(null); - const childRefs = useRef([]); - const isEllipsisActive = lodashGet(containerRef.current, 'offsetWidth') < lodashGet(containerRef.current, 'scrollWidth'); +type HTMLElementWithText = HTMLElement & RNText; + +function DisplayNamesWithToolTip({shouldUseFullTitle, fullTitle, displayNamesWithTooltips, textStyles = [], numberOfLines = 1}: DisplayNamesProps) { + const containerRef = useRef(null); + const childRefs = useRef([]); + const isEllipsisActive = !!containerRef.current?.offsetWidth && !!containerRef.current?.scrollWidth && containerRef.current.offsetWidth < containerRef.current.scrollWidth; /** * We may need to shift the Tooltip horizontally as some of the inline text wraps well with ellipsis, @@ -25,9 +25,9 @@ function DisplayNamesWithToolTip(props) { * @param {Number} index Used to get the Ref to the node at the current index * @returns {Number} Distance to shift the tooltip horizontally */ - const getTooltipShiftX = useCallback((index) => { + const getTooltipShiftX = useCallback((index: number) => { // Only shift the tooltip in case the containerLayout or Refs to the text node are available - if (!containerRef || !childRefs.current[index]) { + if (!containerRef.current || !childRefs.current[index]) { return; } const {width: containerWidth, left: containerLeft} = containerRef.current.getBoundingClientRect(); @@ -46,13 +46,14 @@ function DisplayNamesWithToolTip(props) { return ( // Tokenization of string only support prop numberOfLines on Web (containerRef.current = el)} + style={[textStyles, styles.pRelative, numberOfLines === 1 ? styles.noWrap : {}]} + numberOfLines={numberOfLines || undefined} + ref={containerRef} > - {props.shouldUseFullTitle - ? props.fullTitle - : _.map(props.displayNamesWithTooltips, ({displayName, accountID, avatar, login}, index) => ( + {shouldUseFullTitle + ? fullTitle + : displayNamesWithTooltips.map(({displayName, accountID, avatar, login}, index) => ( + // eslint-disable-next-line react/no-array-index-key - {index < props.displayNamesWithTooltips.length - 1 && } + {index < displayNamesWithTooltips.length - 1 && } ))} {Boolean(isEllipsisActive) && ( - + {/* There is some Gap for real ellipsis so we are adding 4 `.` to cover */} .... @@ -80,8 +80,6 @@ function DisplayNamesWithToolTip(props) { ); } -DisplayNamesWithToolTip.propTypes = propTypes; -DisplayNamesWithToolTip.defaultProps = defaultProps; DisplayNamesWithToolTip.displayName = 'DisplayNamesWithTooltip'; export default DisplayNamesWithToolTip; diff --git a/src/components/DisplayNames/DisplayNamesWithoutTooltip.js b/src/components/DisplayNames/DisplayNamesWithoutTooltip.js deleted file mode 100644 index bdc26d4c3f8d..000000000000 --- a/src/components/DisplayNames/DisplayNamesWithoutTooltip.js +++ /dev/null @@ -1,39 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import Text from '@components/Text'; -import styles from '@styles/styles'; - -const propTypes = { - /** The full title of the DisplayNames component (not split up) */ - fullTitle: PropTypes.string, - - /** Arbitrary styles of the displayName text */ - // eslint-disable-next-line react/forbid-prop-types - textStyles: PropTypes.arrayOf(PropTypes.object), - - /** Number of lines before wrapping */ - numberOfLines: PropTypes.number, -}; - -const defaultProps = { - fullTitle: '', - textStyles: [], - numberOfLines: 1, -}; - -function DisplayNamesWithoutTooltip({textStyles, numberOfLines, fullTitle}) { - return ( - - {fullTitle} - - ); -} - -DisplayNamesWithoutTooltip.propTypes = propTypes; -DisplayNamesWithoutTooltip.defaultProps = defaultProps; -DisplayNamesWithoutTooltip.displayName = 'DisplayNamesWithoutTooltip'; - -export default DisplayNamesWithoutTooltip; diff --git a/src/components/DisplayNames/DisplayNamesWithoutTooltip.tsx b/src/components/DisplayNames/DisplayNamesWithoutTooltip.tsx new file mode 100644 index 000000000000..8779a58cf4d6 --- /dev/null +++ b/src/components/DisplayNames/DisplayNamesWithoutTooltip.tsx @@ -0,0 +1,30 @@ +import React from 'react'; +import {StyleProp, TextStyle} from 'react-native'; +import Text from '@components/Text'; +import styles from '@styles/styles'; + +type DisplayNamesWithoutTooltipProps = { + /** The full title of the DisplayNames component (not split up) */ + fullTitle?: string; + + /** Arbitrary styles of the displayName text */ + textStyles?: StyleProp; + + /** Number of lines before wrapping */ + numberOfLines?: number; +}; + +function DisplayNamesWithoutTooltip({textStyles = [], numberOfLines = 1, fullTitle = ''}: DisplayNamesWithoutTooltipProps) { + return ( + + {fullTitle} + + ); +} + +DisplayNamesWithoutTooltip.displayName = 'DisplayNamesWithoutTooltip'; + +export default DisplayNamesWithoutTooltip; diff --git a/src/components/DisplayNames/displayNamesPropTypes.js b/src/components/DisplayNames/displayNamesPropTypes.js deleted file mode 100644 index c76dc0e075a9..000000000000 --- a/src/components/DisplayNames/displayNamesPropTypes.js +++ /dev/null @@ -1,41 +0,0 @@ -import PropTypes from 'prop-types'; -import sourcePropTypes from '@components/Image/sourcePropTypes'; - -const propTypes = { - /** The full title of the DisplayNames component (not split up) */ - fullTitle: PropTypes.string, - - /** Array of objects that map display names to their corresponding tooltip */ - displayNamesWithTooltips: PropTypes.arrayOf( - PropTypes.shape({ - /** The name to display in bold */ - displayName: PropTypes.string, - - /** The Account ID for the tooltip */ - accountID: PropTypes.number, - - /** The login for the tooltip fallback */ - login: PropTypes.string, - - /** The avatar for the tooltip fallback */ - avatar: PropTypes.oneOfType([PropTypes.string, sourcePropTypes]), - }), - ), - - /** Number of lines before wrapping */ - numberOfLines: PropTypes.number, - - /** Is tooltip needed? When true, triggers complex title rendering */ - tooltipEnabled: PropTypes.bool, - - /** Arbitrary styles of the displayName text */ - textStyles: PropTypes.arrayOf(PropTypes.any), -}; - -const defaultProps = { - numberOfLines: 1, - tooltipEnabled: false, - textStyles: [], -}; - -export {propTypes, defaultProps}; diff --git a/src/components/DisplayNames/index.js b/src/components/DisplayNames/index.js deleted file mode 100644 index 52ec6fb89de2..000000000000 --- a/src/components/DisplayNames/index.js +++ /dev/null @@ -1,25 +0,0 @@ -import React from 'react'; -import {defaultProps, propTypes} from './displayNamesPropTypes'; -import DisplayNamesWithoutTooltip from './DisplayNamesWithoutTooltip'; -import DisplayNamesWithToolTip from './DisplayNamesWithTooltip'; - -function DisplayNames(props) { - if (!props.tooltipEnabled) { - return ( - - ); - } - - // eslint-disable-next-line react/jsx-props-no-spreading - return ; -} - -DisplayNames.propTypes = propTypes; -DisplayNames.defaultProps = defaultProps; -DisplayNames.displayName = 'DisplayNames'; - -export default DisplayNames; diff --git a/src/components/DisplayNames/index.native.js b/src/components/DisplayNames/index.native.js deleted file mode 100644 index e0f9635c0132..000000000000 --- a/src/components/DisplayNames/index.native.js +++ /dev/null @@ -1,22 +0,0 @@ -import React from 'react'; -import Text from '@components/Text'; -import {defaultProps, propTypes} from './displayNamesPropTypes'; - -// As we don't have to show tooltips of the Native platform so we simply render the full display names list. -function DisplayNames(props) { - return ( - - {props.fullTitle} - - ); -} - -DisplayNames.propTypes = propTypes; -DisplayNames.defaultProps = defaultProps; -DisplayNames.displayName = 'DisplayNames'; - -export default DisplayNames; diff --git a/src/components/DisplayNames/index.native.tsx b/src/components/DisplayNames/index.native.tsx new file mode 100644 index 000000000000..8d1d3d2866ed --- /dev/null +++ b/src/components/DisplayNames/index.native.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import Text from '@components/Text'; +import DisplayNamesProps from './types'; + +// As we don't have to show tooltips of the Native platform so we simply render the full display names list. +function DisplayNames({accessibilityLabel, fullTitle, textStyles = [], numberOfLines = 1}: DisplayNamesProps) { + return ( + + {fullTitle} + + ); +} + +DisplayNames.displayName = 'DisplayNames'; + +export default DisplayNames; diff --git a/src/components/DisplayNames/index.tsx b/src/components/DisplayNames/index.tsx new file mode 100644 index 000000000000..f5714fcbfb8b --- /dev/null +++ b/src/components/DisplayNames/index.tsx @@ -0,0 +1,30 @@ +import React from 'react'; +import DisplayNamesWithoutTooltip from './DisplayNamesWithoutTooltip'; +import DisplayNamesWithToolTip from './DisplayNamesWithTooltip'; +import DisplayNamesProps from './types'; + +function DisplayNames({fullTitle, tooltipEnabled, textStyles, numberOfLines, shouldUseFullTitle, displayNamesWithTooltips}: DisplayNamesProps) { + if (!tooltipEnabled) { + return ( + + ); + } + + return ( + + ); +} + +DisplayNames.displayName = 'DisplayNames'; + +export default DisplayNames; diff --git a/src/components/DisplayNames/types.ts b/src/components/DisplayNames/types.ts new file mode 100644 index 000000000000..94e4fc7c39c6 --- /dev/null +++ b/src/components/DisplayNames/types.ts @@ -0,0 +1,44 @@ +import {StyleProp, TextStyle} from 'react-native'; +import type {AvatarSource} from '@libs/UserUtils'; + +type DisplayNameWithTooltip = { + /** The name to display in bold */ + displayName?: string; + + /** The Account ID for the tooltip */ + accountID?: number; + + /** The login for the tooltip fallback */ + login?: string; + + /** The avatar for the tooltip fallback */ + avatar: AvatarSource; +}; + +type DisplayNamesProps = { + /** The full title of the DisplayNames component (not split up) */ + fullTitle: string; + + /** Array of objects that map display names to their corresponding tooltip */ + displayNamesWithTooltips: DisplayNameWithTooltip[]; + + /** Number of lines before wrapping */ + numberOfLines: number; + + /** Is tooltip needed? When true, triggers complex title rendering */ + tooltipEnabled?: boolean; + + /** Arbitrary styles of the displayName text */ + textStyles: StyleProp; + + /** + * Overrides the text that's read by the screen reader when the user interacts with the element. By default, the + * label is constructed by traversing all the children and accumulating all the Text nodes separated by space. + */ + accessibilityLabel?: string; + + /** If the full title needs to be displayed */ + shouldUseFullTitle?: boolean; +}; + +export default DisplayNamesProps; diff --git a/src/components/DistanceRequest/index.js b/src/components/DistanceRequest/index.js index eb6811d02323..e702ab111921 100644 --- a/src/components/DistanceRequest/index.js +++ b/src/components/DistanceRequest/index.js @@ -70,7 +70,7 @@ function DistanceRequest({transactionID, report, transaction, route, isEditingRe const [optimisticWaypoints, setOptimisticWaypoints] = useState(null); const [hasError, setHasError] = useState(false); - const isEditing = lodashGet(route, 'path', '').includes('address'); + const isEditing = Navigation.getActiveRoute().includes('address'); const reportID = lodashGet(report, 'reportID', ''); const waypoints = useMemo(() => optimisticWaypoints || lodashGet(transaction, 'comment.waypoints', {waypoint0: {}, waypoint1: {}}), [optimisticWaypoints, transaction]); const waypointsList = _.keys(waypoints); diff --git a/src/components/EmojiSuggestions.js b/src/components/EmojiSuggestions.tsx similarity index 53% rename from src/components/EmojiSuggestions.js rename to src/components/EmojiSuggestions.tsx index 2a15cfba995e..8ab20cf13ad6 100644 --- a/src/components/EmojiSuggestions.js +++ b/src/components/EmojiSuggestions.tsx @@ -1,7 +1,6 @@ -import PropTypes from 'prop-types'; -import React from 'react'; +import React, {ReactElement} from 'react'; import {View} from 'react-native'; -import _ from 'underscore'; +import type {SimpleEmoji} from '@libs/EmojiTrie'; import * as EmojiUtils from '@libs/EmojiUtils'; import getStyledTextArray from '@libs/GetStyledTextArray'; import styles from '@styles/styles'; @@ -9,77 +8,57 @@ import * as StyleUtils from '@styles/StyleUtils'; import AutoCompleteSuggestions from './AutoCompleteSuggestions'; import Text from './Text'; -const propTypes = { +type MeasureParentContainerCallback = (x: number, y: number, width: number) => void; + +type EmojiSuggestionsProps = { /** The index of the highlighted emoji */ - highlightedEmojiIndex: PropTypes.number, + highlightedEmojiIndex?: number; /** Array of suggested emoji */ - emojis: PropTypes.arrayOf( - PropTypes.shape({ - /** The emoji code */ - code: PropTypes.string.isRequired, - - /** The name of the emoji */ - name: PropTypes.string.isRequired, - - /** Array of different skin tone variants. - * If provided, it will be indexed with props.preferredSkinToneIndex */ - types: PropTypes.arrayOf(PropTypes.string.isRequired), - }), - ).isRequired, + emojis: SimpleEmoji[]; /** Fired when the user selects an emoji */ - onSelect: PropTypes.func.isRequired, + onSelect: (index: number) => void; /** Emoji prefix that follows the colon */ - prefix: PropTypes.string.isRequired, + prefix: string; /** Show that we can use large emoji picker. Depending on available space * and whether the input is expanded, we can have a small or large emoji * suggester. When this value is false, the suggester will have a height of * 2.5 items. When this value is true, the height can be up to 5 items. */ - isEmojiPickerLarge: PropTypes.bool.isRequired, + isEmojiPickerLarge: boolean; /** Stores user's preferred skin tone */ - preferredSkinToneIndex: PropTypes.number.isRequired, + preferredSkinToneIndex: number; /** Meaures the parent container's position and dimensions. */ - measureParentContainer: PropTypes.func, -}; - -const defaultProps = { - highlightedEmojiIndex: 0, - measureParentContainer: () => {}, + measureParentContainer: (callback: MeasureParentContainerCallback) => void; }; /** * Create unique keys for each emoji item - * @param {Object} item - * @param {Number} index - * @returns {String} */ -const keyExtractor = (item, index) => `${item.name}+${index}}`; +const keyExtractor = (item: SimpleEmoji, index: number): string => `${item.name}+${index}}`; -function EmojiSuggestions(props) { +function EmojiSuggestions({emojis, onSelect, prefix, isEmojiPickerLarge, preferredSkinToneIndex, highlightedEmojiIndex = 0, measureParentContainer = () => {}}: EmojiSuggestionsProps) { /** * Render an emoji suggestion menu item component. - * @param {Object} item - * @returns {JSX.Element} */ - const renderSuggestionMenuItem = (item) => { - const styledTextArray = getStyledTextArray(item.name, props.prefix); + const renderSuggestionMenuItem = (item: SimpleEmoji): ReactElement => { + const styledTextArray = getStyledTextArray(item.name, prefix); return ( - {EmojiUtils.getEmojiCodeWithSkinColor(item, props.preferredSkinToneIndex)} + {EmojiUtils.getEmojiCodeWithSkinColor(item, preferredSkinToneIndex)} : - {_.map(styledTextArray, ({text, isColored}, i) => ( + {styledTextArray.map(({text, isColored}) => ( {text} @@ -93,20 +72,18 @@ function EmojiSuggestions(props) { return ( ); } -EmojiSuggestions.propTypes = propTypes; -EmojiSuggestions.defaultProps = defaultProps; EmojiSuggestions.displayName = 'EmojiSuggestions'; export default EmojiSuggestions; diff --git a/src/components/Form.js b/src/components/Form.js index 4d3acf754715..372c7a0c5d9b 100644 --- a/src/components/Form.js +++ b/src/components/Form.js @@ -6,6 +6,7 @@ import {withOnyx} from 'react-native-onyx'; import _ from 'underscore'; import compose from '@libs/compose'; import * as ErrorUtils from '@libs/ErrorUtils'; +import * as ValidationUtils from '@libs/ValidationUtils'; import Visibility from '@libs/Visibility'; import stylePropTypes from '@styles/stylePropTypes'; import styles from '@styles/styles'; @@ -126,14 +127,8 @@ function Form(props) { */ const onValidate = useCallback( (values, shouldClearServerError = true) => { - const trimmedStringValues = {}; - _.each(values, (inputValue, inputID) => { - if (_.isString(inputValue)) { - trimmedStringValues[inputID] = inputValue.trim(); - } else { - trimmedStringValues[inputID] = inputValue; - } - }); + // Trim all string values + const trimmedStringValues = ValidationUtils.prepareValues(values); if (shouldClearServerError) { FormActions.setErrors(props.formID, null); @@ -191,7 +186,7 @@ function Form(props) { return touchedInputErrors; }, - [errors, touchedInputs, props.formID, validate], + [props.formID, validate, errors], ); useEffect(() => { @@ -228,11 +223,14 @@ function Form(props) { return; } + // Trim all string values + const trimmedStringValues = ValidationUtils.prepareValues(inputValues); + // Touches all form inputs so we can validate the entire form _.each(inputRefs.current, (inputRef, inputID) => (touchedInputs.current[inputID] = true)); // Validate form and return early if any errors are found - if (!_.isEmpty(onValidate(inputValues))) { + if (!_.isEmpty(onValidate(trimmedStringValues))) { return; } @@ -242,8 +240,8 @@ function Form(props) { } // Call submit handler - onSubmit(inputValues); - }, [props.formState, onSubmit, inputRefs, inputValues, onValidate, touchedInputs, props.network.isOffline, props.enabledWhenOffline]); + onSubmit(trimmedStringValues); + }, [props.formState.isLoading, props.network.isOffline, props.enabledWhenOffline, inputValues, onValidate, onSubmit]); /** * Loops over Form's children and automatically supplies Form props to them diff --git a/src/components/Form/FormProvider.js b/src/components/Form/FormProvider.js index 92baa9727832..fa0cc3ebd723 100644 --- a/src/components/Form/FormProvider.js +++ b/src/components/Form/FormProvider.js @@ -6,6 +6,7 @@ import _ from 'underscore'; import networkPropTypes from '@components/networkPropTypes'; import {withNetwork} from '@components/OnyxProvider'; import compose from '@libs/compose'; +import * as ValidationUtils from '@libs/ValidationUtils'; import Visibility from '@libs/Visibility'; import stylePropTypes from '@styles/stylePropTypes'; import * as FormActions from '@userActions/FormActions'; @@ -108,14 +109,7 @@ function FormProvider({validate, formID, shouldValidateOnBlur, shouldValidateOnC const onValidate = useCallback( (values, shouldClearServerError = true) => { - const trimmedStringValues = {}; - _.each(values, (inputValue, inputID) => { - if (_.isString(inputValue)) { - trimmedStringValues[inputID] = inputValue.trim(); - } else { - trimmedStringValues[inputID] = inputValue; - } - }); + const trimmedStringValues = ValidationUtils.prepareValues(values); if (shouldClearServerError) { FormActions.setErrors(formID, null); @@ -186,11 +180,14 @@ function FormProvider({validate, formID, shouldValidateOnBlur, shouldValidateOnC return; } + // Prepare values before submitting + const trimmedStringValues = ValidationUtils.prepareValues(inputValues); + // Touches all form inputs so we can validate the entire form _.each(inputRefs.current, (inputRef, inputID) => (touchedInputs.current[inputID] = true)); // Validate form and return early if any errors are found - if (!_.isEmpty(onValidate(inputValues))) { + if (!_.isEmpty(onValidate(trimmedStringValues))) { return; } @@ -199,7 +196,7 @@ function FormProvider({validate, formID, shouldValidateOnBlur, shouldValidateOnC return; } - onSubmit(inputValues); + onSubmit(trimmedStringValues); }, [enabledWhenOffline, formState.isLoading, inputValues, network.isOffline, onSubmit, onValidate]); const registerInput = useCallback( diff --git a/src/components/Header.js b/src/components/Header.js deleted file mode 100644 index 65bf703e6037..000000000000 --- a/src/components/Header.js +++ /dev/null @@ -1,64 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import {View} from 'react-native'; -import _ from 'underscore'; -import styles from '@styles/styles'; -import EnvironmentBadge from './EnvironmentBadge'; -import Text from './Text'; - -const propTypes = { - /** Title of the Header */ - title: PropTypes.oneOfType([PropTypes.string, PropTypes.element]), - - /** Subtitle of the header */ - subtitle: PropTypes.oneOfType([PropTypes.string, PropTypes.element]), - - /** Should we show the environment badge (dev/stg)? */ - shouldShowEnvironmentBadge: PropTypes.bool, - - /** Additional text styles */ - // eslint-disable-next-line react/forbid-prop-types - textStyles: PropTypes.arrayOf(PropTypes.object), -}; - -const defaultProps = { - title: '', - subtitle: '', - textStyles: [], - shouldShowEnvironmentBadge: false, -}; -function Header(props) { - return ( - - - {_.isString(props.title) - ? Boolean(props.title) && ( - - {props.title} - - ) - : props.title} - {/* If there's no subtitle then display a fragment to avoid an empty space which moves the main title */} - {_.isString(props.subtitle) - ? Boolean(props.subtitle) && ( - - {props.subtitle} - - ) - : props.subtitle} - - {props.shouldShowEnvironmentBadge && } - - ); -} - -Header.displayName = 'Header'; -Header.propTypes = propTypes; -Header.defaultProps = defaultProps; -export default Header; diff --git a/src/components/Header.tsx b/src/components/Header.tsx new file mode 100644 index 000000000000..eb9a9b2c55c0 --- /dev/null +++ b/src/components/Header.tsx @@ -0,0 +1,54 @@ +import React, {ReactElement} from 'react'; +import {StyleProp, TextStyle, View} from 'react-native'; +import styles from '@styles/styles'; +import EnvironmentBadge from './EnvironmentBadge'; +import Text from './Text'; + +type HeaderProps = { + /** Title of the Header */ + title?: string | ReactElement; + + /** Subtitle of the header */ + subtitle?: string | ReactElement; + + /** Should we show the environment badge (dev/stg)? */ + shouldShowEnvironmentBadge?: boolean; + + /** Additional text styles */ + textStyles?: StyleProp; +}; + +function Header({title = '', subtitle = '', textStyles = [], shouldShowEnvironmentBadge = false}: HeaderProps) { + return ( + + + {typeof title === 'string' + ? Boolean(title) && ( + + {title} + + ) + : title} + {/* If there's no subtitle then display a fragment to avoid an empty space which moves the main title */} + {typeof subtitle === 'string' + ? Boolean(subtitle) && ( + + {subtitle} + + ) + : subtitle} + + {shouldShowEnvironmentBadge && } + + ); +} + +Header.displayName = 'Header'; + +export default Header; diff --git a/src/components/Hoverable/hoverablePropTypes.js b/src/components/Hoverable/hoverablePropTypes.js deleted file mode 100644 index a3aeaa597d7a..000000000000 --- a/src/components/Hoverable/hoverablePropTypes.js +++ /dev/null @@ -1,33 +0,0 @@ -import PropTypes from 'prop-types'; - -const propTypes = { - /** Whether to disable the hover action */ - disabled: PropTypes.bool, - - /** Children to wrap with Hoverable. */ - children: PropTypes.oneOfType([PropTypes.node, PropTypes.func]).isRequired, - - /** Function that executes when the mouse moves over the children. */ - onHoverIn: PropTypes.func, - - /** Function that executes when the mouse leaves the children. */ - onHoverOut: PropTypes.func, - - /** Direct pass-through of React's onMouseEnter event. */ - onMouseEnter: PropTypes.func, - - /** Direct pass-through of React's onMouseLeave event. */ - onMouseLeave: PropTypes.func, - - /** Decides whether to handle the scroll behaviour to show hover once the scroll ends */ - shouldHandleScroll: PropTypes.bool, -}; - -const defaultProps = { - disabled: false, - onHoverIn: () => {}, - onHoverOut: () => {}, - shouldHandleScroll: false, -}; - -export {propTypes, defaultProps}; diff --git a/src/components/Hoverable/index.native.js b/src/components/Hoverable/index.native.js deleted file mode 100644 index 1c5df276baa6..000000000000 --- a/src/components/Hoverable/index.native.js +++ /dev/null @@ -1,22 +0,0 @@ -import React from 'react'; -import {View} from 'react-native'; -import _ from 'underscore'; -import {defaultProps, propTypes} from './hoverablePropTypes'; - -/** - * On mobile, there is no concept of hovering, so we return a plain wrapper around the component's children, - * where the hover state is always false. - * - * @param {Object} props - * @returns {React.Component} - */ -function Hoverable(props) { - const childrenWithHoverState = _.isFunction(props.children) ? props.children(false) : props.children; - return {childrenWithHoverState}; -} - -Hoverable.propTypes = propTypes; -Hoverable.defaultProps = defaultProps; -Hoverable.displayName = 'Hoverable'; - -export default Hoverable; diff --git a/src/components/Hoverable/index.native.tsx b/src/components/Hoverable/index.native.tsx new file mode 100644 index 000000000000..b3d49db9d96e --- /dev/null +++ b/src/components/Hoverable/index.native.tsx @@ -0,0 +1,17 @@ +import React from 'react'; +import {View} from 'react-native'; +import HoverableProps from './types'; + +/** + * On mobile, there is no concept of hovering, so we return a plain wrapper around the component's children, + * where the hover state is always false. + */ +function Hoverable({children}: HoverableProps) { + const childrenWithHoverState = typeof children === 'function' ? children(false) : children; + + return {childrenWithHoverState}; +} + +Hoverable.displayName = 'Hoverable'; + +export default Hoverable; diff --git a/src/components/Hoverable/index.js b/src/components/Hoverable/index.tsx similarity index 68% rename from src/components/Hoverable/index.js rename to src/components/Hoverable/index.tsx index db752b8845bc..a52dfa296925 100644 --- a/src/components/Hoverable/index.js +++ b/src/components/Hoverable/index.tsx @@ -1,24 +1,23 @@ -import React, {useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react'; +import React, {ForwardedRef, forwardRef, MutableRefObject, ReactElement, RefAttributes, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react'; import {DeviceEventEmitter} from 'react-native'; -import _ from 'underscore'; import * as DeviceCapabilities from '@libs/DeviceCapabilities'; import CONST from '@src/CONST'; -import {defaultProps, propTypes} from './hoverablePropTypes'; +import HoverableProps from './types'; /** * Maps the children of a Hoverable component to * - a function that is called with the parameter * - the child itself if it is the only child - * @param {Array|Function|ReactNode} children - The children to map. - * @param {Object} callbackParam - The parameter to pass to the children function. - * @returns {ReactNode} The mapped children. + * @param children The children to map. + * @param callbackParam The parameter to pass to the children function. + * @returns The mapped children. */ -function mapChildren(children, callbackParam) { - if (_.isArray(children) && children.length === 1) { +function mapChildren(children: ((isHovered: boolean) => ReactElement) | ReactElement | ReactElement[], callbackParam: boolean): ReactElement & RefAttributes { + if (Array.isArray(children)) { return children[0]; } - if (_.isFunction(children)) { + if (typeof children === 'function') { return children(callbackParam); } @@ -27,21 +26,18 @@ function mapChildren(children, callbackParam) { /** * Assigns a ref to an element, either by setting the current property of the ref object or by calling the ref function - * @param {Object|Function} ref - The ref object or function. - * @param {HTMLElement} el - The element to assign the ref to. + * @param ref The ref object or function. + * @param element The element to assign the ref to. */ -function assignRef(ref, el) { +function assignRef(ref: ((instance: HTMLElement | null) => void) | MutableRefObject, element: HTMLElement) { if (!ref) { return; } - - if (_.has(ref, 'current')) { + if (typeof ref === 'function') { + ref(element); + } else if (ref?.current) { // eslint-disable-next-line no-param-reassign - ref.current = el; - } - - if (_.isFunction(ref)) { - ref(el); + ref.current = element; } } @@ -50,16 +46,18 @@ function assignRef(ref, el) { * because nesting Pressables causes issues where the hovered state of the child cannot be easily propagated to the * parent. https://github.com/necolas/react-native-web/issues/1875 */ - -const Hoverable = React.forwardRef(({disabled, onHoverIn, onHoverOut, onMouseEnter, onMouseLeave, children, shouldHandleScroll}, outerRef) => { +function Hoverable( + {disabled = false, onHoverIn = () => {}, onHoverOut = () => {}, onMouseEnter = () => {}, onMouseLeave = () => {}, children, shouldHandleScroll = false}: HoverableProps, + outerRef: ForwardedRef, +) { const [isHovered, setIsHovered] = useState(false); const isScrolling = useRef(false); const isHoveredRef = useRef(false); - const ref = useRef(null); + const ref = useRef(null); const updateIsHoveredOnScrolling = useCallback( - (hovered) => { + (hovered: boolean) => { if (disabled) { return; } @@ -106,14 +104,14 @@ const Hoverable = React.forwardRef(({disabled, onHoverIn, onHoverOut, onMouseEnt * Checks the hover state of a component and updates it based on the event target. * This is necessary to handle cases where the hover state might get stuck due to an unreliable mouseleave trigger, * such as when an element is removed before the mouseleave event is triggered. - * @param {Event} e - The hover event object. + * @param event The hover event object. */ - const unsetHoveredIfOutside = (e) => { + const unsetHoveredIfOutside = (event: MouseEvent) => { if (!ref.current || !isHovered) { return; } - if (ref.current.contains(e.target)) { + if (ref.current.contains(event.target as Node)) { return; } @@ -145,50 +143,44 @@ const Hoverable = React.forwardRef(({disabled, onHoverIn, onHoverOut, onMouseEnt }, [disabled, isHovered, onHoverIn, onHoverOut]); // Expose inner ref to parent through outerRef. This enable us to use ref both in parent and child. - useImperativeHandle(outerRef, () => ref.current, []); + useImperativeHandle(outerRef, () => ref.current, []); const child = useMemo(() => React.Children.only(mapChildren(children, isHovered)), [children, isHovered]); const enableHoveredOnMouseEnter = useCallback( - (el) => { + (event: MouseEvent) => { updateIsHoveredOnScrolling(true); + onMouseEnter(event); - if (_.isFunction(onMouseEnter)) { - onMouseEnter(el); - } - - if (_.isFunction(child.props.onMouseEnter)) { - child.props.onMouseEnter(el); + if (typeof child.props.onMouseEnter === 'function') { + child.props.onMouseEnter(event); } }, [child.props, onMouseEnter, updateIsHoveredOnScrolling], ); const disableHoveredOnMouseLeave = useCallback( - (el) => { + (event: MouseEvent) => { updateIsHoveredOnScrolling(false); + onMouseLeave(event); - if (_.isFunction(onMouseLeave)) { - onMouseLeave(el); - } - - if (_.isFunction(child.props.onMouseLeave)) { - child.props.onMouseLeave(el); + if (typeof child.props.onMouseLeave === 'function') { + child.props.onMouseLeave(event); } }, [child.props, onMouseLeave, updateIsHoveredOnScrolling], ); const disableHoveredOnBlur = useCallback( - (el) => { + (event: MouseEvent) => { // Check if the blur event occurred due to clicking outside the element // and the wrapperView contains the element that caused the blur and reset isHovered - if (!ref.current.contains(el.target) && !ref.current.contains(el.relatedTarget)) { + if (!ref.current?.contains(event.target as Node) && !ref.current?.contains(event.relatedTarget as Node)) { setIsHovered(false); } - if (_.isFunction(child.props.onBlur)) { - child.props.onBlur(el); + if (typeof child.props.onBlur === 'function') { + child.props.onBlur(event); } }, [child.props], @@ -196,9 +188,11 @@ const Hoverable = React.forwardRef(({disabled, onHoverIn, onHoverOut, onMouseEnt // We need to access the ref of a children from both parent and current component // So we pass it to current ref and assign it once again to the child ref prop - const hijackRef = (el) => { + const hijackRef = (el: HTMLElement) => { ref.current = el; - assignRef(child.ref, el); + if (child.ref) { + assignRef(child.ref, el); + } }; if (!DeviceCapabilities.hasHoverSupport()) { @@ -213,10 +207,6 @@ const Hoverable = React.forwardRef(({disabled, onHoverIn, onHoverOut, onMouseEnt onMouseLeave: disableHoveredOnMouseLeave, onBlur: disableHoveredOnBlur, }); -}); - -Hoverable.propTypes = propTypes; -Hoverable.defaultProps = defaultProps; -Hoverable.displayName = 'Hoverable'; +} -export default Hoverable; +export default forwardRef(Hoverable); diff --git a/src/components/Hoverable/types.ts b/src/components/Hoverable/types.ts new file mode 100644 index 000000000000..430b865f50c5 --- /dev/null +++ b/src/components/Hoverable/types.ts @@ -0,0 +1,26 @@ +import {ReactElement} from 'react'; + +type HoverableProps = { + /** Children to wrap with Hoverable. */ + children: ((isHovered: boolean) => ReactElement) | ReactElement; + + /** Whether to disable the hover action */ + disabled?: boolean; + + /** Function that executes when the mouse moves over the children. */ + onHoverIn?: () => void; + + /** Function that executes when the mouse leaves the children. */ + onHoverOut?: () => void; + + /** Direct pass-through of React's onMouseEnter event. */ + onMouseEnter?: (event: MouseEvent) => void; + + /** Direct pass-through of React's onMouseLeave event. */ + onMouseLeave?: (event: MouseEvent) => void; + + /** Decides whether to handle the scroll behaviour to show hover once the scroll ends */ + shouldHandleScroll?: boolean; +}; + +export default HoverableProps; diff --git a/src/components/InvertedFlatList/BaseInvertedFlatList.js b/src/components/InvertedFlatList/BaseInvertedFlatList.js index 802ae373d22a..90b4e02e2e4a 100644 --- a/src/components/InvertedFlatList/BaseInvertedFlatList.js +++ b/src/components/InvertedFlatList/BaseInvertedFlatList.js @@ -4,7 +4,8 @@ import {FlatList as NativeFlatlist, View} from 'react-native'; import _ from 'underscore'; import FlatList from '@components/FlatList'; import * as CollectionUtils from '@libs/CollectionUtils'; -import variables from '@styles/variables'; + +const AUTOSCROLL_TO_TOP_THRESHOLD = 128; const propTypes = { /** Same as FlatList can be any array of anything */ @@ -136,7 +137,7 @@ function BaseInvertedFlatList(props) { windowSize={15} maintainVisibleContentPosition={{ minIndexForVisible: 0, - autoscrollToTopThreshold: variables.listItemHeightNormal, + autoscrollToTopThreshold: AUTOSCROLL_TO_TOP_THRESHOLD, }} inverted /> diff --git a/src/components/LHNOptionsList/OptionRowLHN.js b/src/components/LHNOptionsList/OptionRowLHN.js index 6840c6047571..49bd2bdf5dc1 100644 --- a/src/components/LHNOptionsList/OptionRowLHN.js +++ b/src/components/LHNOptionsList/OptionRowLHN.js @@ -18,6 +18,7 @@ import useLocalize from '@hooks/useLocalize'; import useWindowDimensions from '@hooks/useWindowDimensions'; import DateUtils from '@libs/DateUtils'; import DomUtils from '@libs/DomUtils'; +import {getGroupChatName} from '@libs/GroupChatUtils'; import * as OptionsListUtils from '@libs/OptionsListUtils'; import Permissions from '@libs/Permissions'; import ReportActionComposeFocusManager from '@libs/ReportActionComposeFocusManager'; @@ -155,7 +156,7 @@ function OptionRowLHN(props) { const isGroupChat = optionItem.type === CONST.REPORT.TYPE.CHAT && _.isEmpty(optionItem.chatType) && !optionItem.isThread && lodashGet(optionItem, 'displayNamesWithTooltips.length', 0) > 2; - const fullTitle = isGroupChat ? ReportUtils.getDisplayNamesStringFromTooltips(optionItem.displayNamesWithTooltips) : optionItem.text; + const fullTitle = isGroupChat ? getGroupChatName(ReportUtils.getReport(optionItem.reportID)) : optionItem.text; return ( - {isDeleted ? getDisplayDeleteAmountText() : getDisplayAmountText()} + {displayAmount} {ReportUtils.isSettled(props.iouReport.reportID) && !props.isBillSplit && ( diff --git a/src/components/SelectionList/BaseSelectionList.js b/src/components/SelectionList/BaseSelectionList.js index f749807cbe2d..e3ba0dbd7c2f 100644 --- a/src/components/SelectionList/BaseSelectionList.js +++ b/src/components/SelectionList/BaseSelectionList.js @@ -144,9 +144,6 @@ function BaseSelectionList({ // If `initiallyFocusedOptionKey` is not passed, we fall back to `-1`, to avoid showing the highlight on the first member const [focusedIndex, setFocusedIndex] = useState(() => _.findIndex(flattenedSections.allOptions, (option) => option.keyForList === initiallyFocusedOptionKey)); - // initialFocusedIndex is needed only on component did mount event, don't need to update value - // eslint-disable-next-line react-hooks/exhaustive-deps - const initialFocusedIndex = useMemo(() => (focusedIndex > -1 ? focusedIndex : undefined), []); // Disable `Enter` shortcut if the active element is a button or checkbox const disableEnterShortcut = activeElement && [CONST.ACCESSIBILITY_ROLE.BUTTON, CONST.ACCESSIBILITY_ROLE.CHECKBOX].includes(activeElement.role); @@ -310,9 +307,14 @@ function BaseSelectionList({ /> ); }; - const handleFirstLayout = useCallback(() => { + + const scrollToFocusedIndexOnFirstRender = useCallback(() => { + if (!firstLayoutRef.current) { + return; + } + scrollToIndex(focusedIndex, false); firstLayoutRef.current = false; - }, []); + }, [focusedIndex, scrollToIndex]); const updateAndScrollToFocusedIndex = useCallback( (newFocusedIndex) => { @@ -452,8 +454,7 @@ function BaseSelectionList({ viewabilityConfig={{viewAreaCoveragePercentThreshold: 95}} testID="selection-list" style={[styles.flexGrow0]} - onLayout={handleFirstLayout} - initialScrollIndex={initialFocusedIndex} + onLayout={scrollToFocusedIndexOnFirstRender} /> {children} diff --git a/src/components/Switch.js b/src/components/Switch.tsx similarity index 60% rename from src/components/Switch.js rename to src/components/Switch.tsx index c5adbbef61da..dcd9b559b2cb 100644 --- a/src/components/Switch.js +++ b/src/components/Switch.tsx @@ -1,4 +1,3 @@ -import PropTypes from 'prop-types'; import React, {useEffect, useRef} from 'react'; import {Animated} from 'react-native'; import useNativeDriver from '@libs/useNativeDriver'; @@ -6,15 +5,15 @@ import styles from '@styles/styles'; import CONST from '@src/CONST'; import PressableWithFeedback from './Pressable/PressableWithFeedback'; -const propTypes = { +type SwitchProps = { /** Whether the switch is toggled to the on position */ - isOn: PropTypes.bool.isRequired, + isOn: boolean; /** Callback to fire when the switch is toggled */ - onToggle: PropTypes.func.isRequired, + onToggle: (isOn: boolean) => void; /** Accessibility label for the switch */ - accessibilityLabel: PropTypes.string.isRequired, + accessibilityLabel: string; }; const OFFSET_X = { @@ -22,25 +21,25 @@ const OFFSET_X = { ON: 20, }; -function Switch(props) { - const offsetX = useRef(new Animated.Value(props.isOn ? OFFSET_X.ON : OFFSET_X.OFF)); +function Switch({isOn, onToggle, accessibilityLabel}: SwitchProps) { + const offsetX = useRef(new Animated.Value(isOn ? OFFSET_X.ON : OFFSET_X.OFF)); useEffect(() => { Animated.timing(offsetX.current, { - toValue: props.isOn ? OFFSET_X.ON : OFFSET_X.OFF, + toValue: isOn ? OFFSET_X.ON : OFFSET_X.OFF, duration: 300, useNativeDriver, }).start(); - }, [props.isOn]); + }, [isOn]); return ( props.onToggle(!props.isOn)} - onLongPress={() => props.onToggle(!props.isOn)} + style={[styles.switchTrack, !isOn && styles.switchInactive]} + onPress={() => onToggle(!isOn)} + onLongPress={() => onToggle(!isOn)} role={CONST.ACCESSIBILITY_ROLE.SWITCH} - ariaChecked={props.isOn} - accessibilityLabel={props.accessibilityLabel} + aria-checked={isOn} + accessibilityLabel={accessibilityLabel} // disable hover dim for switch hoverDimmingValue={1} pressDimmingValue={0.8} @@ -50,7 +49,5 @@ function Switch(props) { ); } -Switch.propTypes = propTypes; Switch.displayName = 'Switch'; - export default Switch; diff --git a/src/components/Tooltip/index.js b/src/components/Tooltip/index.js index 19a607220e1c..c7c4428e19a3 100644 --- a/src/components/Tooltip/index.js +++ b/src/components/Tooltip/index.js @@ -15,7 +15,7 @@ const defaultProps = { shouldRender: true, }; -function Tooltip({shouldRender, children, ...props}) { +function Tooltip({shouldRender = true, children, ...props}) { if (!shouldRender) { return children; } diff --git a/src/components/UserDetailsTooltip/index.js b/src/components/UserDetailsTooltip/index.js index ea5cd4337071..6e256ac9ba9e 100644 --- a/src/components/UserDetailsTooltip/index.js +++ b/src/components/UserDetailsTooltip/index.js @@ -15,7 +15,7 @@ const defaultProps = { shouldRender: true, }; -function UserDetailsTooltip({shouldRender, children, ...props}) { +function UserDetailsTooltip({shouldRender = true, children, ...props}) { if (!shouldRender) { return children; } diff --git a/src/libs/EmojiTrie.ts b/src/libs/EmojiTrie.ts index ed03e101ebdc..bec02c56915d 100644 --- a/src/libs/EmojiTrie.ts +++ b/src/libs/EmojiTrie.ts @@ -6,14 +6,20 @@ import CONST from '@src/CONST'; import Timing from './actions/Timing'; import Trie from './Trie'; -type Emoji = { +type HeaderEmoji = { code: string; - header?: boolean; - icon?: React.FC | ImageSourcePropType; - name?: string; + header: boolean; + icon: React.FC | ImageSourcePropType; +}; + +type SimpleEmoji = { + code: string; + name: string; types?: string[]; }; +type Emoji = HeaderEmoji | SimpleEmoji; + type LocalizedEmoji = { name?: string; keywords: string[]; @@ -52,7 +58,7 @@ type EmojiTrie = { * @param name The localized name of the emoji. * @param shouldPrependKeyword Prepend the keyword (instead of append) to the suggestions */ -function addKeywordsToTrie(trie: Trie, keywords: string[], item: Emoji, name: string, shouldPrependKeyword = false) { +function addKeywordsToTrie(trie: Trie, keywords: string[], item: SimpleEmoji, name: string, shouldPrependKeyword = false) { keywords.forEach((keyword) => { const keywordNode = trie.search(keyword); if (!keywordNode) { @@ -85,37 +91,35 @@ function createTrie(lang: SupportedLanguage = CONST.LOCALES.DEFAULT): Trie { - if (!item.name) { - return; - } - - const englishName = item.name; - const localeName = langEmojis?.[item.code]?.name ?? englishName; - - const node = trie.search(localeName); - if (!node) { - trie.add(localeName, {code: item.code, types: item.types, name: localeName, suggestions: []}); - } else { - trie.update(localeName, {code: item.code, types: item.types, name: localeName, suggestions: node.metaData.suggestions}); - } - - const nameParts = getNameParts(localeName).slice(1); // We remove the first part because we already index the full name. - addKeywordsToTrie(trie, nameParts, item, localeName); - - // Add keywords for both the locale language and English to enable users to search using either language. - const keywords = (langEmojis?.[item.code]?.keywords ?? []).concat(isDefaultLocale ? [] : defaultLangEmojis?.[item.code]?.keywords ?? []); - addKeywordsToTrie(trie, keywords, item, localeName); - - /** - * If current language isn't the default, prepend the English name of the emoji in the suggestions as well. - * We do this because when the user types the english name of the emoji, we want to show the emoji in the suggestions before all the others. - */ - if (!isDefaultLocale) { - const englishNameParts = getNameParts(englishName); - addKeywordsToTrie(trie, englishNameParts, item, localeName, true); - } - }); + emojis + .filter((item: Emoji): item is SimpleEmoji => !(item as HeaderEmoji).header) + .forEach((item: SimpleEmoji) => { + const englishName = item.name; + const localeName = langEmojis?.[item.code]?.name ?? englishName; + + const node = trie.search(localeName); + if (!node) { + trie.add(localeName, {code: item.code, types: item.types, name: localeName, suggestions: []}); + } else { + trie.update(localeName, {code: item.code, types: item.types, name: localeName, suggestions: node.metaData.suggestions}); + } + + const nameParts = getNameParts(localeName).slice(1); // We remove the first part because we already index the full name. + addKeywordsToTrie(trie, nameParts, item, localeName); + + // Add keywords for both the locale language and English to enable users to search using either language. + const keywords = (langEmojis?.[item.code]?.keywords ?? []).concat(isDefaultLocale ? [] : defaultLangEmojis?.[item.code]?.keywords ?? []); + addKeywordsToTrie(trie, keywords, item, localeName); + + /** + * If current language isn't the default, prepend the English name of the emoji in the suggestions as well. + * We do this because when the user types the english name of the emoji, we want to show the emoji in the suggestions before all the others. + */ + if (!isDefaultLocale) { + const englishNameParts = getNameParts(englishName); + addKeywordsToTrie(trie, englishNameParts, item, localeName, true); + } + }); return trie; } @@ -125,3 +129,4 @@ const emojiTrie: EmojiTrie = supportedLanguages.reduce((prev, cur) => ({...prev, Timing.end(CONST.TIMING.TRIE_INITIALIZATION); export default emojiTrie; +export type {SimpleEmoji}; diff --git a/src/libs/GroupChatUtils.ts b/src/libs/GroupChatUtils.ts new file mode 100644 index 000000000000..dcb2b13f092c --- /dev/null +++ b/src/libs/GroupChatUtils.ts @@ -0,0 +1,27 @@ +import Onyx, {OnyxEntry} from 'react-native-onyx'; +import ONYXKEYS from '@src/ONYXKEYS'; +import {PersonalDetails, Report} from '@src/types/onyx'; +import * as OptionsListUtils from './OptionsListUtils'; +import * as ReportUtils from './ReportUtils'; + +let allPersonalDetails: OnyxEntry> = {}; +Onyx.connect({ + key: ONYXKEYS.PERSONAL_DETAILS_LIST, + callback: (val) => (allPersonalDetails = val), +}); + +/** + * Returns the report name if the report is a group chat + */ +function getGroupChatName(report: Report): string { + const participants = report.participantAccountIDs ?? []; + const isMultipleParticipantReport = participants.length > 1; + const participantPersonalDetails = OptionsListUtils.getPersonalDetailsForAccountIDs(participants, allPersonalDetails ?? {}); + const displayNamesWithTooltips = ReportUtils.getDisplayNamesWithTooltips(participantPersonalDetails, isMultipleParticipantReport); + return ReportUtils.getDisplayNamesStringFromTooltips(displayNamesWithTooltips); +} + +export { + // eslint-disable-next-line import/prefer-default-export + getGroupChatName, +}; diff --git a/src/libs/Navigation/Navigation.js b/src/libs/Navigation/Navigation.js index 7564955020fa..e12cb5545240 100644 --- a/src/libs/Navigation/Navigation.js +++ b/src/libs/Navigation/Navigation.js @@ -1,4 +1,4 @@ -import {getActionFromState} from '@react-navigation/core'; +import {findFocusedRoute, getActionFromState} from '@react-navigation/core'; import {CommonActions, getPathFromState, StackActions} from '@react-navigation/native'; import _ from 'lodash'; import lodashGet from 'lodash/get'; @@ -73,6 +73,31 @@ const getActiveRouteIndex = function (route, index) { return index; }; +/** + * Gets distance from the path in root navigator. In other words how much screen you have to pop to get to the route with this path. + * The search is limited to 5 screens from the top for performance reasons. + * @param {String} path - Path that you are looking for. + * @return {Number} - Returns distance to path or -1 if the path is not found in root navigator. + */ +function getDistanceFromPathInRootNavigator(path) { + let currentState = navigationRef.getRootState(); + + for (let index = 0; index < 5; index++) { + if (!currentState.routes.length) { + break; + } + + const pathFromState = getPathFromState(currentState, linkingConfig.config); + if (path === pathFromState.substring(1)) { + return index; + } + + currentState = {...currentState, routes: currentState.routes.slice(0, -1), index: currentState.index - 1}; + } + + return -1; +} + /** * Main navigation method for redirecting to a route. * @param {String} route @@ -114,7 +139,6 @@ function goBack(fallbackRoute, shouldEnforceFallback = false, shouldPopToTop = f } const isFirstRouteInNavigator = !getActiveRouteIndex(navigationRef.current.getState()); - if (isFirstRouteInNavigator) { const rootState = navigationRef.getRootState(); const lastRoute = _.last(rootState.routes); @@ -130,6 +154,21 @@ function goBack(fallbackRoute, shouldEnforceFallback = false, shouldPopToTop = f return; } + const isCentralPaneFocused = findFocusedRoute(navigationRef.current.getState()).name === NAVIGATORS.CENTRAL_PANE_NAVIGATOR; + const distanceFromPathInRootNavigator = getDistanceFromPathInRootNavigator(fallbackRoute); + + // Allow CentralPane to use UP with fallback route if the path is not found in root navigator. + if (isCentralPaneFocused && fallbackRoute && distanceFromPathInRootNavigator === -1) { + navigate(fallbackRoute, CONST.NAVIGATION.TYPE.FORCED_UP); + return; + } + + // Add posibility to go back more than one screen in root navigator if that screen is on the stack. + if (isCentralPaneFocused && fallbackRoute && distanceFromPathInRootNavigator > 0) { + navigationRef.current.dispatch(StackActions.pop(distanceFromPathInRootNavigator)); + return; + } + navigationRef.current.goBack(); } diff --git a/src/libs/OptionsListUtils.js b/src/libs/OptionsListUtils.js index 2b4c0ad14012..123efb28bf19 100644 --- a/src/libs/OptionsListUtils.js +++ b/src/libs/OptionsListUtils.js @@ -378,10 +378,7 @@ function getAllReportErrors(report, reportActions) { * @returns {String} */ function getLastMessageTextForReport(report) { - const lastReportAction = _.find( - allSortedReportActions[report.reportID], - (reportAction, key) => ReportActionUtils.shouldReportActionBeVisible(reportAction, key) && reportAction.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, - ); + const lastReportAction = _.find(allSortedReportActions[report.reportID], (reportAction) => ReportActionUtils.shouldReportActionBeVisibleAsLastAction(reportAction)); let lastMessageTextFromReport = ''; const lastActionName = lodashGet(lastReportAction, 'actionName', ''); @@ -415,18 +412,6 @@ function getLastMessageTextForReport(report) { lastMessageTextFromReport = lodashGet(lastReportAction, 'message[0].text', ''); } else { lastMessageTextFromReport = report ? report.lastMessageText || '' : ''; - - // Yeah this is a bit ugly. If the latest report action that is not a whisper has been moderated as pending remove - // then set the last message text to the text of the latest visible action that is not a whisper or the report creation message. - const lastNonWhisper = _.find(allSortedReportActions[report.reportID], (action) => !ReportActionUtils.isWhisperAction(action)) || {}; - if (ReportActionUtils.isPendingRemove(lastNonWhisper)) { - const latestVisibleAction = - _.find( - allSortedReportActions[report.reportID], - (action) => ReportActionUtils.shouldReportActionBeVisibleAsLastAction(action) && !ReportActionUtils.isCreatedAction(action), - ) || {}; - lastMessageTextFromReport = lodashGet(latestVisibleAction, 'message[0].text', ''); - } } return lastMessageTextFromReport; } diff --git a/src/libs/ReportUtils.js b/src/libs/ReportUtils.js index 6bdfd4b2918f..77b9748615ce 100644 --- a/src/libs/ReportUtils.js +++ b/src/libs/ReportUtils.js @@ -4111,6 +4111,10 @@ function getIOUReportActionDisplayMessage(reportAction) { let translationKey; if (originalMessage.type === CONST.IOU.REPORT_ACTION_TYPE.PAY) { const {IOUReportID} = originalMessage; + + // The `REPORT_ACTION_TYPE.PAY` action type is used for both fulfilling existing requests and sending money. To + // differentiate between these two scenarios, we check if the `originalMessage` contains the `IOUDetails` + // property. If it does, it indicates that this is a 'Send money' action. const {amount, currency} = originalMessage.IOUDetails || originalMessage; const formattedAmount = CurrencyUtils.convertToDisplayString(amount, currency); const iouReport = getReport(IOUReportID); diff --git a/src/libs/SidebarUtils.ts b/src/libs/SidebarUtils.ts index 8905616d94ce..58c4a124335d 100644 --- a/src/libs/SidebarUtils.ts +++ b/src/libs/SidebarUtils.ts @@ -10,6 +10,7 @@ import * as OnyxCommon from '@src/types/onyx/OnyxCommon'; import Policy from '@src/types/onyx/Policy'; import Report from '@src/types/onyx/Report'; import ReportAction, {ReportActions} from '@src/types/onyx/ReportAction'; +import * as Task from './actions/Task'; import * as CollectionUtils from './CollectionUtils'; import * as LocalePhoneNumber from './LocalePhoneNumber'; import * as Localize from './Localize'; @@ -435,10 +436,8 @@ function getOptionData( if (lastAction?.actionName === CONST.REPORT.ACTIONS.TYPE.RENAMED) { const newName = lastAction?.originalMessage?.newName ?? ''; result.alternateText = Localize.translate(preferredLocale, 'newRoomPage.roomRenamedTo', {newName}); - } else if (lastAction?.actionName === CONST.REPORT.ACTIONS.TYPE.TASKREOPENED) { - result.alternateText = `${Localize.translate(preferredLocale, 'task.messages.reopened')}`; - } else if (lastAction?.actionName === CONST.REPORT.ACTIONS.TYPE.TASKCOMPLETED) { - result.alternateText = `${Localize.translate(preferredLocale, 'task.messages.completed')}`; + } else if (ReportActionsUtils.isTaskAction(lastAction)) { + result.alternateText = Task.getTaskReportActionMessage(lastAction.actionName, report.reportID, false); } else if ( lastAction?.actionName === CONST.REPORT.ACTIONS.TYPE.ROOMCHANGELOG.INVITE_TO_ROOM || lastAction?.actionName === CONST.REPORT.ACTIONS.TYPE.ROOMCHANGELOG.REMOVE_FROM_ROOM || diff --git a/src/libs/StringUtils.ts b/src/libs/StringUtils.ts index 290380ce2cff..f1dd5a06e828 100644 --- a/src/libs/StringUtils.ts +++ b/src/libs/StringUtils.ts @@ -10,4 +10,57 @@ function sanitizeString(str: string): string { return _.deburr(str).toLowerCase().replaceAll(CONST.REGEX.NON_ALPHABETIC_AND_NON_LATIN_CHARS, ''); } -export default {sanitizeString}; +/** + * Check if the string would be empty if all invisible characters were removed. + */ +function isEmptyString(value: string): boolean { + // \p{C} matches all 'Other' characters + // \p{Z} matches all separators (spaces etc.) + // Source: http://www.unicode.org/reports/tr18/#General_Category_Property + let transformed = value.replace(CONST.REGEX.INVISIBLE_CHARACTERS_GROUPS, ''); + + // Remove other invisible characters that are not in the above unicode categories + transformed = transformed.replace(CONST.REGEX.OTHER_INVISIBLE_CHARACTERS, ''); + + // Check if after removing invisible characters the string is empty + return transformed === ''; +} + +/** + * Remove invisible characters from a string except for spaces and format characters for emoji, and trim it. + */ +function removeInvisibleCharacters(value: string): string { + let result = value; + + // Remove spaces: + // - \u200B: zero-width space + // - \u00A0: non-breaking space + // - \u2060: word joiner + result = result.replace(/[\u200B\u00A0\u2060]/g, ''); + + // Temporarily replace all newlines with non-breaking spaces + // It is necessary because the next step removes all newlines because they are in the (Cc) category + result = result.replace(/\n/g, '\u00A0'); + + // Remove all characters from the 'Other' (C) category except for format characters (Cf) + // because some of them are used for emojis + result = result.replace(/[\p{Cc}\p{Cs}\p{Co}\p{Cn}]/gu, ''); + + // Replace all non-breaking spaces with newlines + result = result.replace(/\u00A0/g, '\n'); + + // Remove characters from the (Cf) category that are not used for emojis + result = result.replace(/[\u200E-\u200F]/g, ''); + + // Remove all characters from the 'Separator' (Z) category except for Space Separator (Zs) + result = result.replace(/[\p{Zl}\p{Zp}]/gu, ''); + + // If the result consist of only invisible characters, return an empty string + if (isEmptyString(result)) { + return ''; + } + + return result.trim(); +} + +export default {sanitizeString, isEmptyString, removeInvisibleCharacters}; diff --git a/src/libs/UserUtils.ts b/src/libs/UserUtils.ts index 30f7cff3fc8f..2d209eab58a3 100644 --- a/src/libs/UserUtils.ts +++ b/src/libs/UserUtils.ts @@ -9,7 +9,7 @@ import hashCode from './hashCode'; type AvatarRange = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24; -type AvatarSource = React.FC | string; +type AvatarSource = React.FC | ImageSourcePropType | string; type LoginListIndicator = ValueOf | ''; diff --git a/src/libs/ValidationUtils.ts b/src/libs/ValidationUtils.ts index 5947d45a6f76..7c49006c10a5 100644 --- a/src/libs/ValidationUtils.ts +++ b/src/libs/ValidationUtils.ts @@ -9,6 +9,7 @@ import {Report} from '@src/types/onyx'; import * as OnyxCommon from '@src/types/onyx/OnyxCommon'; import * as CardUtils from './CardUtils'; import * as LoginUtils from './LoginUtils'; +import StringUtils from './StringUtils'; /** * Implements the Luhn Algorithm, a checksum formula used to validate credit card @@ -73,7 +74,7 @@ function isValidPastDate(date: string | Date): boolean { */ function isRequiredFulfilled(value: string | Date | unknown[] | Record): boolean { if (typeof value === 'string') { - return value.trim().length > 0; + return !StringUtils.isEmptyString(value); } if (isDate(value)) { @@ -352,6 +353,25 @@ function isValidAccountRoute(accountID: number): boolean { return CONST.REGEX.NUMBER.test(String(accountID)) && accountID > 0; } +type ValuesType = Record; + +/** + * This function is used to remove invisible characters from strings before validation and submission. + */ +function prepareValues(values: ValuesType): ValuesType { + const trimmedStringValues: ValuesType = {}; + + for (const [inputID, inputValue] of Object.entries(values)) { + if (typeof inputValue === 'string') { + trimmedStringValues[inputID] = StringUtils.removeInvisibleCharacters(inputValue); + } else { + trimmedStringValues[inputID] = inputValue; + } + } + + return trimmedStringValues; +} + export { meetsMinimumAgeRequirement, meetsMaximumAgeRequirement, @@ -385,4 +405,5 @@ export { isNumeric, isValidAccountRoute, isValidRecoveryCode, + prepareValues, }; diff --git a/src/libs/actions/App.ts b/src/libs/actions/App.ts index ff673d173613..d3366ad6dd8c 100644 --- a/src/libs/actions/App.ts +++ b/src/libs/actions/App.ts @@ -17,6 +17,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import * as OnyxTypes from '@src/types/onyx'; +import {SelectedTimezone} from '@src/types/onyx/PersonalDetails'; import type {OnyxData} from '@src/types/onyx/Request'; import * as Policy from './Policy'; import * as Session from './Session'; @@ -442,7 +443,7 @@ function openProfile(personalDetails: OnyxTypes.PersonalDetails) { if (oldTimezoneData?.automatic ?? true) { newTimezoneData = { automatic: true, - selected: Intl.DateTimeFormat().resolvedOptions().timeZone, + selected: Intl.DateTimeFormat().resolvedOptions().timeZone as SelectedTimezone, }; } diff --git a/src/libs/actions/IOU.js b/src/libs/actions/IOU.js index 7a6c297d2cd6..aa07f0e7ca34 100644 --- a/src/libs/actions/IOU.js +++ b/src/libs/actions/IOU.js @@ -2188,15 +2188,13 @@ function deleteMoneyRequest(transactionID, reportAction, isSingleTransactionView // STEP 7: Navigate the user depending on which page they are on and which resources were deleted if (isSingleTransactionView && shouldDeleteTransactionThread && !shouldDeleteIOUReport) { // Pop the deleted report screen before navigating. This prevents navigating to the Concierge chat due to the missing report. - Navigation.goBack(ROUTES.HOME); - Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(iouReport.reportID)); + Navigation.goBack(ROUTES.REPORT_WITH_ID.getRoute(iouReport.reportID)); return; } if (shouldDeleteIOUReport) { // Pop the deleted report screen before navigating. This prevents navigating to the Concierge chat due to the missing report. - Navigation.goBack(ROUTES.HOME); - Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(iouReport.chatReportID)); + Navigation.goBack(ROUTES.REPORT_WITH_ID.getRoute(iouReport.chatReportID)); } } diff --git a/src/pages/home/HeaderView.js b/src/pages/home/HeaderView.js index 27ee820de02f..0b7ed4aedcd6 100644 --- a/src/pages/home/HeaderView.js +++ b/src/pages/home/HeaderView.js @@ -21,6 +21,7 @@ import Tooltip from '@components/Tooltip'; import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; import withWindowDimensions, {windowDimensionsPropTypes} from '@components/withWindowDimensions'; import compose from '@libs/compose'; +import {getGroupChatName} from '@libs/GroupChatUtils'; import * as HeaderUtils from '@libs/HeaderUtils'; import reportWithoutHasDraftSelector from '@libs/OnyxSelectors/reportWithoutHasDraftSelector'; import * as OptionsListUtils from '@libs/OptionsListUtils'; @@ -82,7 +83,7 @@ function HeaderView(props) { const isTaskReport = ReportUtils.isTaskReport(props.report); const reportHeaderData = !isTaskReport && !isChatThread && props.report.parentReportID ? props.parentReport : props.report; // Use sorted display names for the title for group chats on native small screen widths - const title = ReportUtils.isGroupChat(props.report) ? ReportUtils.getDisplayNamesStringFromTooltips(displayNamesWithTooltips) : ReportUtils.getReportName(reportHeaderData); + const title = ReportUtils.isGroupChat(props.report) ? getGroupChatName(props.report) : ReportUtils.getReportName(reportHeaderData); const subtitle = ReportUtils.getChatRoomSubtitle(reportHeaderData); const parentNavigationSubtitleData = ReportUtils.getParentNavigationSubtitle(reportHeaderData); const isConcierge = ReportUtils.hasSingleParticipant(props.report) && _.contains(participants, CONST.ACCOUNT_ID.CONCIERGE); diff --git a/src/pages/iou/steps/MoneyRequestAmountForm.js b/src/pages/iou/steps/MoneyRequestAmountForm.js index d1f8848f18f2..734b7d9794b4 100644 --- a/src/pages/iou/steps/MoneyRequestAmountForm.js +++ b/src/pages/iou/steps/MoneyRequestAmountForm.js @@ -36,6 +36,9 @@ const propTypes = { /** Fired when submit button pressed, saves the given amount and navigates to the next page */ onSubmitButtonPress: PropTypes.func.isRequired, + + /** The current tab we have navigated to in the request modal. String that corresponds to the request type. */ + selectedTab: PropTypes.oneOf([CONST.TAB.DISTANCE, CONST.TAB.MANUAL, CONST.TAB.SCAN]), }; const defaultProps = { @@ -43,6 +46,7 @@ const defaultProps = { currency: CONST.CURRENCY.USD, forwardedRef: null, isEditing: false, + selectedTab: CONST.TAB.MANUAL, }; /** @@ -64,7 +68,7 @@ const AMOUNT_VIEW_ID = 'amountView'; const NUM_PAD_CONTAINER_VIEW_ID = 'numPadContainerView'; const NUM_PAD_VIEW_ID = 'numPadView'; -function MoneyRequestAmountForm({amount, currency, isEditing, forwardedRef, onCurrencyButtonPress, onSubmitButtonPress}) { +function MoneyRequestAmountForm({amount, currency, isEditing, forwardedRef, onCurrencyButtonPress, onSubmitButtonPress, selectedTab}) { const {isExtraSmallScreenHeight} = useWindowDimensions(); const {translate, toLocaleDigit, numberFormat} = useLocalize(); @@ -237,6 +241,10 @@ function MoneyRequestAmountForm({amount, currency, isEditing, forwardedRef, onCu const buttonText = isEditing ? translate('common.save') : translate('common.next'); const canUseTouchScreen = DeviceCapabilities.canUseTouchScreen(); + useEffect(() => { + setFormError(''); + }, [selectedTab]); + return ( (textInput.current = e)} onCurrencyButtonPress={navigateToCurrencySelectionPage} onSubmitButtonPress={navigateToNextPage} + selectedTab={selectedTab} /> ); diff --git a/src/pages/settings/Report/ReportSettingsPage.js b/src/pages/settings/Report/ReportSettingsPage.js index df23e16e80cd..c89e08c8c7b3 100644 --- a/src/pages/settings/Report/ReportSettingsPage.js +++ b/src/pages/settings/Report/ReportSettingsPage.js @@ -15,6 +15,7 @@ import ScreenWrapper from '@components/ScreenWrapper'; import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; import compose from '@libs/compose'; +import {getGroupChatName} from '@libs/GroupChatUtils'; import Navigation from '@libs/Navigation/Navigation'; import * as ReportUtils from '@libs/ReportUtils'; import withReportOrNotFound from '@pages/home/report/withReportOrNotFound'; @@ -78,7 +79,7 @@ function ReportSettingsPage(props) { const shouldShowNotificationPref = !isMoneyRequestReport && report.notificationPreference !== CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN; const roomNameLabel = translate(isMoneyRequestReport ? 'workspace.editor.nameInputLabel' : 'newRoomPage.roomName'); - const reportName = ReportUtils.getReportName(props.report); + const reportName = ReportUtils.isGroupChat(props.report) ? getGroupChatName(props.report) : ReportUtils.getReportName(props.report); const shouldShowWriteCapability = !isMoneyRequestReport; diff --git a/src/pages/workspace/WorkspaceSettingsPage.js b/src/pages/workspace/WorkspaceSettingsPage.js index b78e593e8c8a..789726a915a8 100644 --- a/src/pages/workspace/WorkspaceSettingsPage.js +++ b/src/pages/workspace/WorkspaceSettingsPage.js @@ -20,6 +20,7 @@ import compose from '@libs/compose'; import Navigation from '@libs/Navigation/Navigation'; import * as ReportUtils from '@libs/ReportUtils'; import * as UserUtils from '@libs/UserUtils'; +import * as ValidationUtils from '@libs/ValidationUtils'; import styles from '@styles/styles'; import * as Policy from '@userActions/Policy'; import CONST from '@src/CONST'; @@ -77,7 +78,7 @@ function WorkspaceSettingsPage({policy, currencyList, windowWidth, route}) { const errors = {}; const name = values.name.trim(); - if (!name || !name.length) { + if (!ValidationUtils.isRequiredFulfilled(name)) { errors.name = 'workspace.editor.nameIsRequiredError'; } else if ([...name].length > CONST.WORKSPACE_NAME_CHARACTER_LIMIT) { // Uses the spread syntax to count the number of Unicode code points instead of the number of UTF-16 diff --git a/src/stories/Form.stories.js b/src/stories/Form.stories.js index d385cf0613e6..e09ec755b68d 100644 --- a/src/stories/Form.stories.js +++ b/src/stories/Form.stories.js @@ -10,6 +10,7 @@ import StatePicker from '@components/StatePicker'; import Text from '@components/Text'; import TextInput from '@components/TextInput'; import NetworkConnection from '@libs/NetworkConnection'; +import * as ValidationUtils from '@libs/ValidationUtils'; import styles from '@styles/styles'; import * as FormActions from '@userActions/FormActions'; import CONST from '@src/CONST'; @@ -177,28 +178,28 @@ const defaultArgs = { submitButtonText: 'Submit', validate: (values) => { const errors = {}; - if (!values.routingNumber) { + if (!ValidationUtils.isRequiredFulfilled(values.routingNumber)) { errors.routingNumber = 'Please enter a routing number'; } - if (!values.accountNumber) { + if (!ValidationUtils.isRequiredFulfilled(values.accountNumber)) { errors.accountNumber = 'Please enter an account number'; } - if (!values.street) { + if (!ValidationUtils.isRequiredFulfilled(values.street)) { errors.street = 'Please enter an address'; } - if (!values.dob) { + if (!ValidationUtils.isRequiredFulfilled(values.dob)) { errors.dob = 'Please enter your date of birth'; } - if (!values.pickFruit) { + if (!ValidationUtils.isRequiredFulfilled(values.pickFruit)) { errors.pickFruit = 'Please select a fruit'; } - if (!values.pickAnotherFruit) { + if (!ValidationUtils.isRequiredFulfilled(values.pickAnotherFruit)) { errors.pickAnotherFruit = 'Please select a fruit'; } - if (!values.state) { + if (!ValidationUtils.isRequiredFulfilled(values.state)) { errors.state = 'Please select a state'; } - if (!values.checkbox) { + if (!ValidationUtils.isRequiredFulfilled(values.checkbox)) { errors.checkbox = 'You must accept the Terms of Service to continue'; } return errors; diff --git a/src/styles/StyleUtils.ts b/src/styles/StyleUtils.ts index 65a440099ecf..de936570291f 100644 --- a/src/styles/StyleUtils.ts +++ b/src/styles/StyleUtils.ts @@ -1018,7 +1018,7 @@ function getAutoCompleteSuggestionContainerStyle(itemsHeight: number): ViewStyle /** * Select the correct color for text. */ -function getColoredBackgroundStyle(isColored: boolean): ViewStyle { +function getColoredBackgroundStyle(isColored: boolean): TextStyle { return {backgroundColor: isColored ? themeColors.link : undefined}; } @@ -1263,22 +1263,53 @@ function getDropDownButtonHeight(buttonSize: ButtonSizeValue): ViewStyle { /** * Returns fitting fontSize and lineHeight values in order to prevent large amounts from being cut off on small screen widths. */ -function getAmountFontSizeAndLineHeight(baseFontSize: number, baseLineHeight: number, isSmallScreenWidth: boolean, windowWidth: number): TextStyle { +function getAmountFontSizeAndLineHeight(isSmallScreenWidth: boolean, windowWidth: number, displayAmountLength: number, numberOfParticipant: number): TextStyle { let toSubtract = 0; + const baseFontSize = variables.fontSizeXLarge; + const baseLineHeight = variables.lineHeightXXLarge; - if (isSmallScreenWidth) { - const widthDifference = variables.mobileResponsiveWidthBreakpoint - windowWidth; + const numberOfAvatar = numberOfParticipant < 4 ? numberOfParticipant : 4; + const differentWithMaxLength = 17 - displayAmountLength; + + // with a window width is more than 420px the maximum amount will not be cut off with the maximum avatar displays + if (isSmallScreenWidth && windowWidth < 420) { + // Based on width Difference we can see the max length of amount can be displayed with the number of avatars. + // From there we can calculate subtract in accordance with the number of avatar and the length of amount text + const widthDifference = 420 - windowWidth; switch (true) { - case widthDifference > 450: + // It is very rare for native devices to have a width smaller than 350px so add a constant subtract here + case widthDifference > 70: toSubtract = 11; break; - case widthDifference > 400: - toSubtract = 8; + case widthDifference > 60: + if (18 - numberOfAvatar * 2 < displayAmountLength) { + toSubtract = numberOfAvatar * 2 - differentWithMaxLength; + } + break; + case widthDifference > 50: + if (19 - numberOfAvatar * 2 < displayAmountLength) { + toSubtract = (numberOfAvatar - 1) * 2 + 1 - differentWithMaxLength; + } + break; + case widthDifference > 40: + if (20 - numberOfAvatar * 2 < displayAmountLength) { + toSubtract = (numberOfAvatar - 1) * 2 - differentWithMaxLength; + } + break; + case widthDifference > 30: + if (21 - numberOfAvatar * 2 < displayAmountLength) { + toSubtract = (numberOfAvatar - 1) * 2 - 1 - differentWithMaxLength; + } break; - case widthDifference > 350: - toSubtract = 4; + case widthDifference > 20: + if (22 - numberOfAvatar * 2 < displayAmountLength) { + toSubtract = (numberOfAvatar - 2) * 2 - differentWithMaxLength; + } break; default: + if (displayAmountLength + numberOfAvatar === 21) { + toSubtract = 3; + } break; } } diff --git a/src/styles/getTooltipStyles.ts b/src/styles/getTooltipStyles.ts index 2613cb791688..5c90027e10fd 100644 --- a/src/styles/getTooltipStyles.ts +++ b/src/styles/getTooltipStyles.ts @@ -250,6 +250,7 @@ export default function getTooltipStyles( // We are adding this to prevent the tooltip text from being selected and copied on CTRL + A. ...styles.userSelectNone, + ...styles.pointerEventsNone, }, textStyle: { color: themeColors.textReversed, diff --git a/tests/actions/IOUTest.js b/tests/actions/IOUTest.js index 76098d72f52e..1c258f6e0f37 100644 --- a/tests/actions/IOUTest.js +++ b/tests/actions/IOUTest.js @@ -2635,14 +2635,14 @@ describe('actions/IOU', () => { // Then we expect to navigate to the iou report - expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute(IOU_REPORT_ID)); + expect(Navigation.goBack).toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute(IOU_REPORT_ID)); }); it('navigate the user correctly to the chat Report when appropriate', () => { // When we delete the money request and we should delete the IOU report IOU.deleteMoneyRequest(transaction.transactionID, createIOUAction, false); // Then we expect to navigate to the chat report - expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute(chatReport.reportID)); + expect(Navigation.goBack).toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute(chatReport.reportID)); }); }); diff --git a/tests/unit/isEmptyString.js b/tests/unit/isEmptyString.js new file mode 100644 index 000000000000..0de9b791fa97 --- /dev/null +++ b/tests/unit/isEmptyString.js @@ -0,0 +1,92 @@ +import _ from 'underscore'; +import enEmojis from '../../assets/emojis/en'; +import StringUtils from '../../src/libs/StringUtils'; + +describe('libs/StringUtils.isEmptyString', () => { + it('basic tests', () => { + expect(StringUtils.isEmptyString('test')).toBe(false); + expect(StringUtils.isEmptyString('test test')).toBe(false); + expect(StringUtils.isEmptyString('test test test')).toBe(false); + expect(StringUtils.isEmptyString(' ')).toBe(true); + }); + it('trim spaces', () => { + expect(StringUtils.isEmptyString(' test')).toBe(false); + expect(StringUtils.isEmptyString('test ')).toBe(false); + expect(StringUtils.isEmptyString(' test ')).toBe(false); + }); + it('remove invisible characters', () => { + expect(StringUtils.isEmptyString('\u200B')).toBe(true); + expect(StringUtils.isEmptyString('\u200B')).toBe(true); + expect(StringUtils.isEmptyString('\u200B ')).toBe(true); + expect(StringUtils.isEmptyString('\u200B \u200B')).toBe(true); + expect(StringUtils.isEmptyString('\u200B \u200B ')).toBe(true); + }); + it('remove invisible characters (Cc)', () => { + expect(StringUtils.isEmptyString('\u0000')).toBe(true); + expect(StringUtils.isEmptyString('\u0001')).toBe(true); + expect(StringUtils.isEmptyString('\u0009')).toBe(true); + }); + it('remove invisible characters (Cf)', () => { + expect(StringUtils.isEmptyString('\u200E')).toBe(true); + expect(StringUtils.isEmptyString('\u200F')).toBe(true); + expect(StringUtils.isEmptyString('\u2060')).toBe(true); + }); + it('remove invisible characters (Cs)', () => { + expect(StringUtils.isEmptyString('\uD800')).toBe(true); + expect(StringUtils.isEmptyString('\uD801')).toBe(true); + expect(StringUtils.isEmptyString('\uD802')).toBe(true); + }); + it('remove invisible characters (Co)', () => { + expect(StringUtils.isEmptyString('\uE000')).toBe(true); + expect(StringUtils.isEmptyString('\uE001')).toBe(true); + expect(StringUtils.isEmptyString('\uE002')).toBe(true); + }); + it('remove invisible characters (Zl)', () => { + expect(StringUtils.isEmptyString('\u2028')).toBe(true); + expect(StringUtils.isEmptyString('\u2029')).toBe(true); + expect(StringUtils.isEmptyString('\u202A')).toBe(true); + }); + it('basic check emojis not removed', () => { + expect(StringUtils.isEmptyString('😀')).toBe(false); + }); + it('all emojis not removed', () => { + _.keys(enEmojis).forEach((key) => { + expect(StringUtils.isEmptyString(key)).toBe(false); + }); + }); + it('remove invisible characters (editpad)', () => { + expect(StringUtils.isEmptyString('\u0020')).toBe(true); + expect(StringUtils.isEmptyString('\u00A0')).toBe(true); + expect(StringUtils.isEmptyString('\u2000')).toBe(true); + expect(StringUtils.isEmptyString('\u2001')).toBe(true); + expect(StringUtils.isEmptyString('\u2002')).toBe(true); + expect(StringUtils.isEmptyString('\u2003')).toBe(true); + expect(StringUtils.isEmptyString('\u2004')).toBe(true); + expect(StringUtils.isEmptyString('\u2005')).toBe(true); + expect(StringUtils.isEmptyString('\u2006')).toBe(true); + expect(StringUtils.isEmptyString('\u2007')).toBe(true); + expect(StringUtils.isEmptyString('\u2008')).toBe(true); + expect(StringUtils.isEmptyString('\u2009')).toBe(true); + expect(StringUtils.isEmptyString('\u200A')).toBe(true); + expect(StringUtils.isEmptyString('\u2028')).toBe(true); + expect(StringUtils.isEmptyString('\u205F')).toBe(true); + expect(StringUtils.isEmptyString('\u3000')).toBe(true); + expect(StringUtils.isEmptyString(' ')).toBe(true); + }); + it('other tests', () => { + expect(StringUtils.isEmptyString('\u200D')).toBe(true); + expect(StringUtils.isEmptyString('\uD83C\uDFF4\uDB40\uDC67\uDB40\uDC62\uDB40\uDC65\uDB40\uDC6E\uDB40\uDC67\uDB40\uDC7F')).toBe(false); + expect(StringUtils.isEmptyString('\uD83C')).toBe(true); + expect(StringUtils.isEmptyString('\uDFF4')).toBe(true); + expect(StringUtils.isEmptyString('\uDB40')).toBe(true); + expect(StringUtils.isEmptyString('\uDC67')).toBe(true); + expect(StringUtils.isEmptyString('\uDC62')).toBe(true); + expect(StringUtils.isEmptyString('\uDC65')).toBe(true); + expect(StringUtils.isEmptyString('\uDC6E')).toBe(true); + expect(StringUtils.isEmptyString('\uDC67')).toBe(true); + expect(StringUtils.isEmptyString('\uDC7F')).toBe(true); + + // A special test, an invisible character from other Unicode categories than format and control + expect(StringUtils.isEmptyString('\u3164')).toBe(true); + }); +}); diff --git a/tests/unit/removeInvisibleCharacters.js b/tests/unit/removeInvisibleCharacters.js new file mode 100644 index 000000000000..98d1c7c71baf --- /dev/null +++ b/tests/unit/removeInvisibleCharacters.js @@ -0,0 +1,152 @@ +import _ from 'underscore'; +import enEmojis from '../../assets/emojis/en'; +import StringUtils from '../../src/libs/StringUtils'; + +describe('libs/StringUtils.removeInvisibleCharacters', () => { + it('basic tests', () => { + expect(StringUtils.removeInvisibleCharacters('test')).toBe('test'); + expect(StringUtils.removeInvisibleCharacters('test test')).toBe('test test'); + expect(StringUtils.removeInvisibleCharacters('abcdefghijklmnopqrstuvwxyz')).toBe('abcdefghijklmnopqrstuvwxyz'); + expect(StringUtils.removeInvisibleCharacters('ABCDEFGHIJKLMNOPQRSTUVWXYZ')).toBe('ABCDEFGHIJKLMNOPQRSTUVWXYZ'); + expect(StringUtils.removeInvisibleCharacters('0123456789')).toBe('0123456789'); + expect(StringUtils.removeInvisibleCharacters('!@#$%^&*()_+-=[]{}|;:\'",.<>/?`~')).toBe('!@#$%^&*()_+-=[]{}|;:\'",.<>/?`~'); + expect(StringUtils.removeInvisibleCharacters('')).toBe(''); + expect(StringUtils.removeInvisibleCharacters(' ')).toBe(''); + }); + it('other alphabets, list of all characters', () => { + // arabic + expect(StringUtils.removeInvisibleCharacters('أبجدية عربية')).toBe('أبجدية عربية'); + // chinese + expect(StringUtils.removeInvisibleCharacters('的一是了我不人在他们')).toBe('的一是了我不人在他们'); + // cyrillic + expect(StringUtils.removeInvisibleCharacters('абвгдезиклмнопр')).toBe('абвгдезиклмнопр'); + // greek + expect(StringUtils.removeInvisibleCharacters('αβγδεζηθικλμνξοπρ')).toBe('αβγδεζηθικλμνξοπρ'); + // hebrew + expect(StringUtils.removeInvisibleCharacters('אבגדהוזחטיכלמנ')).toBe('אבגדהוזחטיכלמנ'); + // hindi + expect(StringUtils.removeInvisibleCharacters('अआइईउऊऋऍऎ')).toBe('अआइईउऊऋऍऎ'); + // japanese + expect(StringUtils.removeInvisibleCharacters('あいうえおかきくけこ')).toBe('あいうえおかきくけこ'); + // korean + expect(StringUtils.removeInvisibleCharacters('가나다라마바사아자')).toBe('가나다라마바사아자'); + // thai + expect(StringUtils.removeInvisibleCharacters('กขคงจฉชซ')).toBe('กขคงจฉชซ'); + }); + it('trim spaces', () => { + expect(StringUtils.removeInvisibleCharacters(' test')).toBe('test'); + expect(StringUtils.removeInvisibleCharacters('test ')).toBe('test'); + expect(StringUtils.removeInvisibleCharacters(' test ')).toBe('test'); + }); + it('remove invisible characters', () => { + expect(StringUtils.removeInvisibleCharacters('test\u200B')).toBe('test'); + expect(StringUtils.removeInvisibleCharacters('test\u200Btest')).toBe('testtest'); + expect(StringUtils.removeInvisibleCharacters('test\u200B test')).toBe('test test'); + expect(StringUtils.removeInvisibleCharacters('test\u200B test\u200B')).toBe('test test'); + expect(StringUtils.removeInvisibleCharacters('test\u200B test\u200B test')).toBe('test test test'); + }); + it('remove invisible characters (Cc)', () => { + expect(StringUtils.removeInvisibleCharacters('test\u0000')).toBe('test'); + expect(StringUtils.removeInvisibleCharacters('test\u0001')).toBe('test'); + expect(StringUtils.removeInvisibleCharacters('test\u0009')).toBe('test'); + }); + it('remove invisible characters (Cf)', () => { + expect(StringUtils.removeInvisibleCharacters('test\u200E')).toBe('test'); + expect(StringUtils.removeInvisibleCharacters('test\u200F')).toBe('test'); + expect(StringUtils.removeInvisibleCharacters('test\u2060')).toBe('test'); + }); + it('check other visible characters (Cs)', () => { + expect(StringUtils.removeInvisibleCharacters('test\uD800')).toBe('test'); + expect(StringUtils.removeInvisibleCharacters('test\uD801')).toBe('test'); + expect(StringUtils.removeInvisibleCharacters('test\uD802')).toBe('test'); + }); + it('check other visible characters (Co)', () => { + expect(StringUtils.removeInvisibleCharacters('test\uE000')).toBe('test'); + expect(StringUtils.removeInvisibleCharacters('test\uE001')).toBe('test'); + expect(StringUtils.removeInvisibleCharacters('test\uE002')).toBe('test'); + }); + it('remove invisible characters (Cn)', () => { + expect(StringUtils.removeInvisibleCharacters('test\uFFF0')).toBe('test'); + expect(StringUtils.removeInvisibleCharacters('test\uFFF1')).toBe('test'); + expect(StringUtils.removeInvisibleCharacters('test\uFFF2')).toBe('test'); + }); + it('remove invisible characters (Zl)', () => { + expect(StringUtils.removeInvisibleCharacters('test\u2028')).toBe('test'); + expect(StringUtils.removeInvisibleCharacters('test\u2029')).toBe('test'); + }); + it('basic check emojis not removed', () => { + expect(StringUtils.removeInvisibleCharacters('test😀')).toBe('test😀'); + expect(StringUtils.removeInvisibleCharacters('test😀😀')).toBe('test😀😀'); + expect(StringUtils.removeInvisibleCharacters('test😀😀😀')).toBe('test😀😀😀'); + }); + it('all emojis not removed', () => { + _.keys(enEmojis).forEach((key) => { + expect(StringUtils.removeInvisibleCharacters(key)).toBe(key); + }); + }); + it('remove invisible characters (editpad)', () => { + expect(StringUtils.removeInvisibleCharacters('test\u0020')).toBe('test'); + expect(StringUtils.removeInvisibleCharacters('test\u00A0')).toBe('test'); + expect(StringUtils.removeInvisibleCharacters('test\u2000')).toBe('test'); + expect(StringUtils.removeInvisibleCharacters('test\u2001')).toBe('test'); + expect(StringUtils.removeInvisibleCharacters('test\u2002')).toBe('test'); + expect(StringUtils.removeInvisibleCharacters('test\u2003')).toBe('test'); + expect(StringUtils.removeInvisibleCharacters('test\u2004')).toBe('test'); + expect(StringUtils.removeInvisibleCharacters('test\u2005')).toBe('test'); + expect(StringUtils.removeInvisibleCharacters('test\u2006')).toBe('test'); + expect(StringUtils.removeInvisibleCharacters('test\u2007')).toBe('test'); + expect(StringUtils.removeInvisibleCharacters('test\u2008')).toBe('test'); + expect(StringUtils.removeInvisibleCharacters('test\u2009')).toBe('test'); + expect(StringUtils.removeInvisibleCharacters('test\u200A')).toBe('test'); + expect(StringUtils.removeInvisibleCharacters('test\u2028')).toBe('test'); + expect(StringUtils.removeInvisibleCharacters('test\u205F')).toBe('test'); + expect(StringUtils.removeInvisibleCharacters('test\u3000')).toBe('test'); + expect(StringUtils.removeInvisibleCharacters('test ')).toBe('test'); + }); + it('other tests', () => { + expect(StringUtils.removeInvisibleCharacters('\uD83D\uDE36\u200D\uD83C\uDF2B\uFE0F')).toBe('😶‍🌫️'); + expect(StringUtils.removeInvisibleCharacters('⁠test')).toBe('test'); + expect(StringUtils.removeInvisibleCharacters('test⁠test')).toBe('testtest'); + expect(StringUtils.removeInvisibleCharacters('  ‎ ‏ ⁠   ')).toBe(''); + expect(StringUtils.removeInvisibleCharacters('te ‎‏⁠st')).toBe('test'); + expect(StringUtils.removeInvisibleCharacters('\uD83C\uDFF4\uDB40\uDC67\uDB40\uDC62\uDB40\uDC65\uDB40\uDC6E\uDB40\uDC67\uDB40\uDC7F')).toBe('🏴󠁧󠁢󠁥󠁮󠁧󠁿'); + }); + it('special scenarios', () => { + // Normally we do not remove this character, because it is used in Emojis. + // But if the String consist of only invisible characters, we can safely remove it. + expect(StringUtils.removeInvisibleCharacters('\u200D')).toBe(''); + expect(StringUtils.removeInvisibleCharacters('⁠')).toBe(''); + }); + it('check multiline', () => { + expect(StringUtils.removeInvisibleCharacters('test\ntest')).toBe('test\ntest'); + expect(StringUtils.removeInvisibleCharacters('test\n')).toBe('test'); + expect(StringUtils.removeInvisibleCharacters('\ntest')).toBe('test'); + expect(StringUtils.removeInvisibleCharacters('\n')).toBe(''); + expect(StringUtils.removeInvisibleCharacters('\n\n')).toBe(''); + expect(StringUtils.removeInvisibleCharacters('\n\n\n')).toBe(''); + + // multiple newlines + expect(StringUtils.removeInvisibleCharacters('test\n\ntest')).toBe('test\n\ntest'); + expect(StringUtils.removeInvisibleCharacters('test\n\n\ntest')).toBe('test\n\n\ntest'); + expect(StringUtils.removeInvisibleCharacters('test\n\n\n\ntest')).toBe('test\n\n\n\ntest'); + + // multiple newlinest with multiple texts + expect(StringUtils.removeInvisibleCharacters('test\ntest\ntest')).toBe('test\ntest\ntest'); + expect(StringUtils.removeInvisibleCharacters('test\ntest\ntest\ntest')).toBe('test\ntest\ntest\ntest'); + expect(StringUtils.removeInvisibleCharacters('test\ntest\ntest\ntest\ntest')).toBe('test\ntest\ntest\ntest\ntest'); + + // multiple newlines with multiple texts and spaces + expect(StringUtils.removeInvisibleCharacters('test\n\ntest\ntest\ntest\ntest')).toBe('test\n\ntest\ntest\ntest\ntest'); + + expect(StringUtils.removeInvisibleCharacters('test\n \ntest')).toBe('test\n \ntest'); + }); + it('check markdown styling', () => { + expect(StringUtils.removeInvisibleCharacters('# test\n** test **')).toBe('# test\n** test **'); + expect(StringUtils.removeInvisibleCharacters('# test\n** test **\n')).toBe('# test\n** test **'); + expect(StringUtils.removeInvisibleCharacters('# test\n**test**\n~~test~~')).toBe('# test\n**test**\n~~test~~'); + + // multiple newlines + expect(StringUtils.removeInvisibleCharacters('# test\n\n** test **')).toBe('# test\n\n** test **'); + expect(StringUtils.removeInvisibleCharacters('# test\n\n** test **\n')).toBe('# test\n\n** test **'); + }); +});