diff --git a/.eslintrc.js b/.eslintrc.js
index 0661183101ab..33be8cb62fcd 100644
--- a/.eslintrc.js
+++ b/.eslintrc.js
@@ -51,6 +51,10 @@ const restrictedImportPaths = [
name: '@styles/theme/illustrations',
message: 'Do not import theme illustrations directly. Please use the `useThemeIllustrations` hook instead.',
},
+ {
+ name: 'date-fns/locale',
+ message: "Do not import 'date-fns/locale' directly. Please use the submodule import instead, like 'date-fns/locale/en-GB'.",
+ },
];
const restrictedImportPatterns = [
diff --git a/android/app/build.gradle b/android/app/build.gradle
index c919f731795e..c30938a6da5c 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -98,8 +98,8 @@ android {
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
multiDexEnabled rootProject.ext.multiDexEnabled
- versionCode 1001045302
- versionName "1.4.53-2"
+ versionCode 1001045401
+ versionName "1.4.54-1"
}
flavorDimensions "default"
diff --git a/docs/articles/expensify-classic/expenses/expenses/Add-an-expense.md b/docs/articles/expensify-classic/expenses/expenses/Add-an-expense.md
new file mode 100644
index 000000000000..461748c6af9e
--- /dev/null
+++ b/docs/articles/expensify-classic/expenses/expenses/Add-an-expense.md
@@ -0,0 +1,102 @@
+---
+title: Add an expense
+description: Create a new expense in Expensify
+---
+
+
+You can add an expense automatically with SmartScan or enter the expense details manually.
+
+# SmartScan a receipt
+
+{% include selector.html values="desktop, mobile" %}
+
+{% include option.html value="desktop" %}
+
+You can upload pictures of your receipts to Expensify and SmartScan will automatically capture the receipt details including the merchant, date, total, and currency.
+
+1. Click the **Expenses** tab.
+2. Click the + icon in the top right and select **Scan receipt**.
+3. Upload a saved image of a receipt.
+
+{% include end-option.html %}
+
+{% include option.html value="mobile" %}
+
+You can use the Expensify mobile app to take a picture of your receipts and SmartScan will automatically capture the receipt details including the merchant, date, total, and currency.
+
+1. Open the mobile app and tap the camera icon in the bottom right corner.
+2. Upload or take a photo of your receipt.
+ - **To upload a photo** of a receipt you have saved on your phone, tap the photo icon in the left corner.
+ - **To take a photo**, tap the camera icon in the right corner to select the mode, make sure all of the transaction details are clearly visible,and then take the photo.
+ - Normal Mode: Upload one receipt.
+ - Rapid Fire Mode: Upload multiple receipts at once.
+
+You can open any receipt and click **Fill out details myself** to add or edit the merchant, date, current, total, description, category, or add attendees for group expenses. You can also add the expense to a report, determine if it is a reimbursable expense, or split the expense if multiple expense categories are included on one receipt.
+
+{% include info.html %}
+**For iPhones**: You can also hard press the Expensify app icon on your phone to open a shortcut that automatically opens the camera to SmartScan a receipt.
+{% include end-info.html %}
+
+{% include end-option.html %}
+
+{% include end-selector.html %}
+
+# Email a receipt
+
+You can also email receipts to SmartScan by sending them to receipts@expensify.com from an email address tied to your Expensify account (either a primary or secondary email). SmartScan will automatically pull all of the details from the receipt, fill them in for you, and add the receipt to the Expenses tab on your account.
+
+{% include info.html %}
+**For copilots**: To ensure a receipt is routed to the Expensify account you are copiloting instead of your own account, email the receipt to receipts@expensify.com with the email address of the account you are copiloting as the subject line of the email.
+{% include end-info.html %}
+
+# Add an expense manually
+
+{% include selector.html values="desktop, mobile" %}
+
+{% include option.html value="desktop" %}
+
+1. Click the **Expenses** tab.
+2. Click the + icon in the top right.
+3. Select the type of expense.
+ - **Manually create**: Manually enter receipt details.
+ - **Scan receipt**: Upload a saved image of a receipt.
+ - **Create multiple**: Manually enter multiple expenses at once.
+ - **Time**: Create an expense based on hours.
+ - **Distance**: Create an expense based on distance.
+ - Manually Create: Manually enter the distance details for the expense.
+ - Create from Map: Enter the start and end destination and Expensify will help you create a receipt for the trip.
+4. Click **Save**.
+{% include end-option.html %}
+
+{% include option.html value="mobile" %}
+
+1. Tap the ☰ menu icon in the top left.
+2. Tap **Expenses**.
+3. Tap the + icon in the top right.
+4. Tap the correct expense type and enter the expense details.
+ - **Manually create**: Manually enter receipt details.
+ - **Time**: Enter work time and rate.
+ - **Manually create (Distance)**: Manually enter trip details by total distance.
+ - **Odometer**: Manually enter trip details by start and end odometer readings.
+ - **Start GPS**: Track distance while using the Expensify app to automatically calculate the distance in real time during the trip.
+5. Tap **Save**.
+{% include end-option.html %}
+
+{% include end-selector.html %}
+
+{% include info.html %}
+If you are an employee under a company workspace, you may not see all of the different expense type options depending on your company’s workspace settings.
+{% include end-info.html %}
+
+# FAQs
+
+**What’s the difference between a reimbursable and non-reimbursable expense?**
+
+- Reimbursable expenses are things that you pay for with your own money that the company has agreed to pay you back for (like business travel paid for with personal funds).
+- Non-reimbursable expenses are things you pay for with company money that need to be documented for accounting purposes (like a lunch paid for with a company card).
+
+{% include info.html %}
+If you are an employee under a company workspace, your expenses may automatically be configured as reimbursable or non-reimbursable depending on the details that are entered. If an expense is incorrectly labeled, you must reach out to an admin to have it corrected.
+{% include end-info.html %}
+
+
diff --git a/docs/articles/expensify-classic/expenses/expenses/Create-Expenses.md b/docs/articles/expensify-classic/expenses/expenses/Create-Expenses.md
deleted file mode 100644
index 7fa714189542..000000000000
--- a/docs/articles/expensify-classic/expenses/expenses/Create-Expenses.md
+++ /dev/null
@@ -1,128 +0,0 @@
----
-title: Create-Expenses.md
-description: This is an article that shows you all the ways that you can create Expenses in Expensify!
----
-
-
-# About
-Whether you're using SmartScan for automatic expense creation, or manually creating, splitting, or duplicating expenses, you can rest assured your expenses will be correctly tracked in Expensify.
-
-# How-to Create Expenses
-## Using SmartScan
-Use the big green camera button within the Expensify mobile app to snap a photo of your physical receipt to have it SmartScanned.
-For digital or emailed receipts, simply forward them to receipts@expensify.com and it will be SmartScanned and added to your Expensify account.
-
-There’s no need to keep the app open and most SmartScans are finished within the hour. If more details are needed, Concierge will reach out to you with a friendly message.
-## Using the Mobile App
-Simply tap the **+** icon in the top-right corner
-Choose **Expense** and then select **Manually Create**.
-If you don't have a receipt handy or want to add it later, fill in your expense details and click the **Save** button.
-## Using the Expensify Website
-Log into the Expensify website
-Click on the **Expenses** page and find the **New Expense** dropdown.
-Select your expense type, hit the **Save** button and you're all set.
-You can then add details like the Merchant and Category, attach a receipt image, and even add a description.
-# How to Split an Expense
-Splitting an expense in Expensify allows you to break down a single expense into multiple expenses. Each split expense is treated as an individual expense which can be categorized and tagged separately. The same receipt image will be attached to all of the split expenses, allowing you to divide a single expense into smaller, more manageable expenses.
-To split an expense on the mobile app:
-
-1. Open an expense.
-2. At the bottom of the screen, tap **More Options**.
-3. Then, use the **Split** button to divide the expense.
-
-To split an expense on the Expensify website:
-
-1. Click on the expense you want to split.
-2. Click on the **Split** button.
- - On the Expenses page, this button is at the top.
- - Within an individual expense, you'll find it at the bottom.
-3. This will automatically be split in two, but you can decide how many expenses you want to split it into by clicking on the **Add Split** button.
- - Remember, the total of all pieces must add up to the original expense amount, and no piece can have a $0.00 amount (or you won't be able to save the changes).
-
-# How to Create Bulk Expenses
-
-If you have multiple saved receipt images or PDFs to upload, you can drag and drop them onto your Expenses page in batches of ten - this will start the SmartScan process for all of them.
-
-You can also create a number of future 'placeholder' expenses for your recurring expenses (such as recurring bills or subscriptions) which you don't have receipts for by clicking *New Expense > Create Multiple* to quickly add multiple expenses in batches of up to ten.
-
-# How to Edit Bulk Expenses
-Editing expenses in bulk will allow you to apply the same coding across multiple expenses and is a web-only feature. To bulk edit expenses:
-Go to the Expenses page.
-To narrow down your selection, use the filters (e.g. "Merchant" and "Draft") to find the specific expenses you want to edit.
-Select all the expenses you want to edit.
-Click on the **Edit Multiple** button at the top of the page.
-# How to Edit Expenses on a Report
-If you’d like to edit expenses within a Draft report:
-
-1. Click on the Report containing all the expenses.
-2. Click on **Details**.
-3. Click on the Pencil icon.
-3. Select the **Edit Multiple** button.
-
-If you've already submitted your report, you'll need to Retract it or have it Unapproved first before you can edit the expenses.
-
-# FAQ
-
-## Does Expensify account for duplicates?
-
-Yes, Expensify will account for duplicates. Expensify works behind the scenes to identify duplicate expenses before they are submitted, warning employees when they exist. If a duplicate expense is submitted, the same warning will be shown to the approver responsible for reviewing the report.
-
-If two expenses are SmartScanned on the same day for the same amount, they will be flagged as duplicates unless:
-The expenses were split from a single expense,
-The expenses were imported from a credit card, or
-Matching email receipts sent to receipts@expensify.com were received with different timestamps.
-
-## How do I resolve a duplicate expense?
-
-If Concierge has let you know it's flagged a receipt as a duplicate, scanning the receipt again will trigger the same duplicate flagging.Users have the ability to resolve duplicates by either deleting the duplicated transactions, merging them, or ignoring them (if they are legitimately separate expenses of the same date and amount).
-
-## How do I recover a duplicate or undelete an expense?
-
-To recover a duplicate or undelete an expense:
-Log into your Expensify account on the website and navigate to the Expenses page
-Use the filters to search for deleted expenses by selecting the "Deleted" filter
-Select the checkbox next to the expenses you want to restore
-Click the **Undelete** button and you're all set. You’ll find the expense on your Expenses page again.
-
-# Deep Dive
-
-## What are the different Expense statuses?
-
-There are a number of different expense statuses in Expensify:
-1. **Personal**: Personal expenses are not yet part of a report (and therefore unsubmitted) and are not viewable by anyone but the expense creator/owner.
-2. **Draft**: Draft expenses are seen as still in progress, and are unsubmitted. Your Policy Admin will be able to view them, making this a collaborative step toward reimbursement.
-3. **Processing**: Processing expenses are submitted, but waiting for approval.
-4. **Approved**: If it's a non-reimbursable expense, the workflow is complete at this point. If it's a reimbursable expense, you're one step closer to getting paid.
-5. **Reimbursed**: Reimbursed expenses are fully settled. You can check the Report Comments to see when you'll get paid.
-6. **Closed**: Sometimes an expense accidentally ends up on your Individual Policy, falling into the Closed status. You’ll need to reopen the report and change the Policy by clicking on the **Details** tab in order to resubmit your report.
-
-## What are Violations?
-
-Violations represent errors or discrepancies that Expensify has picked up and need to be corrected before a report can be successfully submitted. The one exception is when an expense comment is added, it will override the violation - as the user is providing a valid reason for submission.
-
-To enable or configure violations according to your policy, go to **Settings > Policies > _Policy Name_ > Expenses > Expense Violations**. Keep in mind that Expensify includes certain system mandatory violations that can't be disabled, even if your policy has violations turned off.
-
-You can spot violations by the exclamation marks (!) attached to expenses. Hovering over the symbol will provide a brief description and you can find more detailed information below the list of expenses. The two types of violations are:
-1. **Red**: These indicate violations directly tied to your report's Policy settings. They are clear rule violations that must be addressed before submission.
-2. **Yellow**: Concierge will highlight items that require attention but may not necessarily need corrective action. For example, if a receipt was SmartScanned and then the amount was modified, we’ll bring it to your attention so that it can be manually reviewed.
-
-## How to Track Attendees
-
-Attendee tracking makes it easy to track shared expenses and maintain transparency in your group spending.
-
-Internal attendees are considered users within your policies or domain. To add internal attendees on mobile or web:
-1. Click or tap the **Attendee** field within your expense.
-2. Select the internal attendees you'd like to add from the list of searchable users.
-3. You can continue adding more attendees or save the Expense.
-
-External attendees are considered users outside your group policy or domain. To add external attendees:
-1. Click or tap the **Attendee** field within your expense.
-2. Type in the individual's name or email address.
-3. Tap **Add** to include the attendee.
-4. You can continue adding more attendees or save the Expense.
-
-To remove an attendee from an expense:
-
-1. Open the expense.
-2. Click or tap the **Attendees** field to display the list of attendees.
-3. From the list, de-select the attendees you'd like to remove from the expense.
diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist
index 2e4c69affd9b..eb51f4499583 100644
--- a/ios/NewExpensify/Info.plist
+++ b/ios/NewExpensify/Info.plist
@@ -19,7 +19,7 @@
CFBundlePackageTypeAPPLCFBundleShortVersionString
- 1.4.53
+ 1.4.54CFBundleSignature????CFBundleURLTypes
@@ -40,7 +40,7 @@
CFBundleVersion
- 1.4.53.2
+ 1.4.54.1ITSAppUsesNonExemptEncryptionLSApplicationQueriesSchemes
diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist
index b6aab5371ba4..faf2eea9f738 100644
--- a/ios/NewExpensifyTests/Info.plist
+++ b/ios/NewExpensifyTests/Info.plist
@@ -15,10 +15,10 @@
CFBundlePackageTypeBNDLCFBundleShortVersionString
- 1.4.53
+ 1.4.54CFBundleSignature????CFBundleVersion
- 1.4.53.2
+ 1.4.54.1
diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist
index 6b0ad08aad65..8abab817dfb1 100644
--- a/ios/NotificationServiceExtension/Info.plist
+++ b/ios/NotificationServiceExtension/Info.plist
@@ -11,9 +11,9 @@
CFBundleName$(PRODUCT_NAME)CFBundleShortVersionString
- 1.4.53
+ 1.4.54CFBundleVersion
- 1.4.53.2
+ 1.4.54.1NSExtensionNSExtensionPointIdentifier
diff --git a/package-lock.json b/package-lock.json
index a84a72dd5167..ba15feeacae6 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "new.expensify",
- "version": "1.4.53-2",
+ "version": "1.4.54-1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "new.expensify",
- "version": "1.4.53-2",
+ "version": "1.4.54-1",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
@@ -56,7 +56,6 @@
"expo-av": "~13.10.4",
"expo-image": "1.10.1",
"expo-image-manipulator": "11.8.0",
- "fbjs": "^3.0.2",
"htmlparser2": "^7.2.0",
"idb-keyval": "^6.2.1",
"jest-expo": "50.0.1",
@@ -65,7 +64,6 @@
"lottie-react-native": "6.4.1",
"mapbox-gl": "^2.15.0",
"onfido-sdk-ui": "14.15.0",
- "patch-package": "^8.0.0",
"process": "^0.11.10",
"prop-types": "^15.7.2",
"pusher-js": "8.3.0",
@@ -89,7 +87,6 @@
"react-native-gesture-handler": "2.14.1",
"react-native-google-places-autocomplete": "2.5.6",
"react-native-haptic-feedback": "^2.2.0",
- "react-native-image-pan-zoom": "^2.1.12",
"react-native-image-picker": "^7.0.3",
"react-native-image-size": "git+https://github.com/Expensify/react-native-image-size#8393b7e58df6ff65fd41f60aee8ece8822c91e2b",
"react-native-key-command": "^1.0.6",
@@ -127,7 +124,6 @@
"react-web-config": "^1.0.0",
"react-webcam": "^7.1.1",
"react-window": "^1.8.9",
- "save": "^2.4.0",
"semver": "^7.5.2",
"shim-keyboard-event-key": "^1.0.3",
"underscore": "^1.13.1"
@@ -225,6 +221,7 @@
"jest-transformer-svg": "^2.0.1",
"memfs": "^4.6.0",
"onchange": "^7.1.0",
+ "patch-package": "^8.0.0",
"portfinder": "^1.0.28",
"prettier": "^2.8.8",
"pusher-js-mock": "^0.3.3",
@@ -19786,6 +19783,9 @@
},
"node_modules/@yarnpkg/lockfile": {
"version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz",
+ "integrity": "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==",
+ "dev": true,
"license": "BSD-2-Clause"
},
"node_modules/7zip-bin": {
@@ -20721,6 +20721,9 @@
},
"node_modules/async": {
"version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/async/-/async-3.2.4.tgz",
+ "integrity": "sha512-iAB+JbDEGXhyIUavoDl9WP/Jj106Kz9DEn1DPgYw5ruDn0e3Wgi3sKFm55sASdGBNOQB8F59d9qQ7deqrHA8wQ==",
+ "dev": true,
"license": "MIT"
},
"node_modules/async-each": {
@@ -25442,6 +25445,9 @@
},
"node_modules/duplexer": {
"version": "0.1.2",
+ "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz",
+ "integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==",
+ "dev": true,
"license": "MIT"
},
"node_modules/duplexify": {
@@ -27058,19 +27064,6 @@
"node": ">= 0.6"
}
},
- "node_modules/event-stream": {
- "version": "4.0.1",
- "license": "MIT",
- "dependencies": {
- "duplexer": "^0.1.1",
- "from": "^0.1.7",
- "map-stream": "0.0.7",
- "pause-stream": "^0.0.11",
- "split": "^1.0.1",
- "stream-combiner": "^0.2.2",
- "through": "^2.3.8"
- }
- },
"node_modules/event-target-shim": {
"version": "5.0.1",
"license": "MIT",
@@ -28551,10 +28544,6 @@
"node": ">= 0.6"
}
},
- "node_modules/from": {
- "version": "0.1.7",
- "license": "MIT"
- },
"node_modules/from2": {
"version": "2.3.0",
"dev": true,
@@ -34135,6 +34124,9 @@
},
"node_modules/klaw-sync": {
"version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/klaw-sync/-/klaw-sync-6.0.0.tgz",
+ "integrity": "sha512-nIeuVSzdCCs6TDPTqI8w1Yre34sSq7AkZ4B3sfOBbI2CgVSB4Du4aLQijFU2+lhAFCwt9+42Hel6lQNIv6AntQ==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.1.11"
@@ -34413,10 +34405,6 @@
"version": "4.17.21",
"license": "MIT"
},
- "node_modules/lodash.assign": {
- "version": "4.2.0",
- "license": "MIT"
- },
"node_modules/lodash.clonedeep": {
"version": "4.5.0",
"dev": true,
@@ -34855,10 +34843,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/map-stream": {
- "version": "0.0.7",
- "license": "MIT"
- },
"node_modules/map-visit": {
"version": "1.0.0",
"devOptional": true,
@@ -35996,10 +35980,6 @@
"node": ">=4"
}
},
- "node_modules/mingo": {
- "version": "1.3.3",
- "license": "MIT"
- },
"node_modules/minimalistic-assert": {
"version": "1.0.1",
"license": "ISC"
@@ -37409,6 +37389,9 @@
},
"node_modules/patch-package": {
"version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/patch-package/-/patch-package-8.0.0.tgz",
+ "integrity": "sha512-da8BVIhzjtgScwDJ2TtKsfT5JFWz1hYoBl9rUQ1f38MC2HwnEIkK8VN3dKMKcP7P7bvvgzNDbfNHtx3MsQb5vA==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"@yarnpkg/lockfile": "^1.1.0",
@@ -37437,6 +37420,9 @@
},
"node_modules/patch-package/node_modules/ansi-styles": {
"version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
@@ -37450,6 +37436,9 @@
},
"node_modules/patch-package/node_modules/chalk": {
"version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+ "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.1.0",
@@ -37464,6 +37453,9 @@
},
"node_modules/patch-package/node_modules/color-convert": {
"version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
@@ -37474,10 +37466,16 @@
},
"node_modules/patch-package/node_modules/color-name": {
"version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "dev": true,
"license": "MIT"
},
"node_modules/patch-package/node_modules/has-flag": {
"version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -37485,6 +37483,9 @@
},
"node_modules/patch-package/node_modules/open": {
"version": "7.4.2",
+ "resolved": "https://registry.npmjs.org/open/-/open-7.4.2.tgz",
+ "integrity": "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"is-docker": "^2.0.0",
@@ -37499,6 +37500,9 @@
},
"node_modules/patch-package/node_modules/rimraf": {
"version": "2.7.1",
+ "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz",
+ "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==",
+ "dev": true,
"license": "ISC",
"dependencies": {
"glob": "^7.1.3"
@@ -37509,6 +37513,9 @@
},
"node_modules/patch-package/node_modules/slash": {
"version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz",
+ "integrity": "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==",
+ "dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
@@ -37516,6 +37523,9 @@
},
"node_modules/patch-package/node_modules/supports-color": {
"version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
@@ -37526,6 +37536,9 @@
},
"node_modules/patch-package/node_modules/tmp": {
"version": "0.0.33",
+ "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz",
+ "integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"os-tmpdir": "~1.0.2"
@@ -37622,16 +37635,6 @@
"node": ">=8"
}
},
- "node_modules/pause-stream": {
- "version": "0.0.11",
- "license": [
- "MIT",
- "Apache2"
- ],
- "dependencies": {
- "through": "~2.3"
- }
- },
"node_modules/pbf": {
"version": "3.2.1",
"license": "BSD-3-Clause",
@@ -39089,14 +39092,6 @@
"react-native": ">=0.60.0"
}
},
- "node_modules/react-native-image-pan-zoom": {
- "version": "2.1.12",
- "license": "ISC",
- "peerDependencies": {
- "react": "*",
- "react-native": "*"
- }
- },
"node_modules/react-native-image-picker": {
"version": "7.0.3",
"license": "MIT",
@@ -41261,16 +41256,6 @@
"truncate-utf8-bytes": "^1.0.0"
}
},
- "node_modules/save": {
- "version": "2.5.0",
- "license": "ISC",
- "dependencies": {
- "async": "^3.2.2",
- "event-stream": "^4.0.1",
- "lodash.assign": "^4.2.0",
- "mingo": "1"
- }
- },
"node_modules/sax": {
"version": "1.2.4",
"license": "ISC"
@@ -42461,14 +42446,6 @@
"node": ">= 0.10.0"
}
},
- "node_modules/stream-combiner": {
- "version": "0.2.2",
- "license": "MIT",
- "dependencies": {
- "duplexer": "~0.1.1",
- "through": "~2.3.4"
- }
- },
"node_modules/stream-each": {
"version": "1.2.3",
"dev": true,
diff --git a/package.json b/package.json
index e0f357fd0f8a..d1974b99b91e 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "new.expensify",
- "version": "1.4.53-2",
+ "version": "1.4.54-1",
"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.",
@@ -107,7 +107,6 @@
"expo-av": "~13.10.4",
"expo-image": "1.10.1",
"expo-image-manipulator": "11.8.0",
- "fbjs": "^3.0.2",
"htmlparser2": "^7.2.0",
"idb-keyval": "^6.2.1",
"jest-expo": "50.0.1",
@@ -116,7 +115,6 @@
"lottie-react-native": "6.4.1",
"mapbox-gl": "^2.15.0",
"onfido-sdk-ui": "14.15.0",
- "patch-package": "^8.0.0",
"process": "^0.11.10",
"prop-types": "^15.7.2",
"pusher-js": "8.3.0",
@@ -140,7 +138,6 @@
"react-native-gesture-handler": "2.14.1",
"react-native-google-places-autocomplete": "2.5.6",
"react-native-haptic-feedback": "^2.2.0",
- "react-native-image-pan-zoom": "^2.1.12",
"react-native-image-picker": "^7.0.3",
"react-native-image-size": "git+https://github.com/Expensify/react-native-image-size#8393b7e58df6ff65fd41f60aee8ece8822c91e2b",
"react-native-key-command": "^1.0.6",
@@ -178,7 +175,6 @@
"react-web-config": "^1.0.0",
"react-webcam": "^7.1.1",
"react-window": "^1.8.9",
- "save": "^2.4.0",
"semver": "^7.5.2",
"shim-keyboard-event-key": "^1.0.3",
"underscore": "^1.13.1"
@@ -276,6 +272,7 @@
"jest-transformer-svg": "^2.0.1",
"memfs": "^4.6.0",
"onchange": "^7.1.0",
+ "patch-package": "^8.0.0",
"portfinder": "^1.0.28",
"prettier": "^2.8.8",
"pusher-js-mock": "^0.3.3",
diff --git a/src/CONST.ts b/src/CONST.ts
index 0135070cdff3..d15d82cd7c7c 100755
--- a/src/CONST.ts
+++ b/src/CONST.ts
@@ -43,10 +43,21 @@ const keyInputRightArrow = KeyCommand?.constants?.keyInputRightArrow ?? 'keyInpu
// describes if a shortcut key can cause navigation
const KEYBOARD_SHORTCUT_NAVIGATION_TYPE = 'NAVIGATION_SHORTCUT';
+const chatTypes = {
+ POLICY_ANNOUNCE: 'policyAnnounce',
+ POLICY_ADMINS: 'policyAdmins',
+ DOMAIN_ALL: 'domainAll',
+ POLICY_ROOM: 'policyRoom',
+ POLICY_EXPENSE_CHAT: 'policyExpenseChat',
+ SELF_DM: 'selfDM',
+} as const;
+
// Explicit type annotation is required
const cardActiveStates: number[] = [2, 3, 4, 7];
const CONST = {
+ MERGED_ACCOUNT_PREFIX: 'MERGED_',
+ DEFAULT_POLICY_ROOM_CHAT_TYPES: [chatTypes.POLICY_ADMINS, chatTypes.POLICY_ANNOUNCE, chatTypes.DOMAIN_ALL],
ANDROID_PACKAGE_NAME,
ANIMATED_TRANSITION: 300,
ANIMATED_TRANSITION_FROM_VALUE: 100,
@@ -346,6 +357,9 @@ const CONST = {
INSTALLED: 'installed',
NOT_INSTALLED: 'not-installed',
},
+ TAX_RATES: {
+ NAME_MAX_LENGTH: 50,
+ },
PLATFORM: {
IOS: 'ios',
ANDROID: 'android',
@@ -732,14 +746,7 @@ const CONST = {
IOU: 'iou',
TASK: 'task',
},
- CHAT_TYPE: {
- POLICY_ANNOUNCE: 'policyAnnounce',
- POLICY_ADMINS: 'policyAdmins',
- DOMAIN_ALL: 'domainAll',
- POLICY_ROOM: 'policyRoom',
- POLICY_EXPENSE_CHAT: 'policyExpenseChat',
- SELF_DM: 'selfDM',
- },
+ CHAT_TYPE: chatTypes,
WORKSPACE_CHAT_ROOMS: {
ANNOUNCE: '#announce',
ADMINS: '#admins',
diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts
index d7f3104cd8b4..e91b4d491423 100755
--- a/src/ONYXKEYS.ts
+++ b/src/ONYXKEYS.ts
@@ -339,6 +339,8 @@ const ONYXKEYS = {
WORKSPACE_DESCRIPTION_FORM_DRAFT: 'workspaceDescriptionFormDraft',
WORKSPACE_RATE_AND_UNIT_FORM: 'workspaceRateAndUnitForm',
WORKSPACE_RATE_AND_UNIT_FORM_DRAFT: 'workspaceRateAndUnitFormDraft',
+ WORKSPACE_TAX_CUSTOM_NAME: 'workspaceTaxCustomName',
+ WORKSPACE_TAX_CUSTOM_NAME_DRAFT: 'workspaceTaxCustomNameDraft',
POLICY_CREATE_DISTANCE_RATE_FORM: 'policyCreateDistanceRateForm',
POLICY_CREATE_DISTANCE_RATE_FORM_DRAFT: 'policyCreateDistanceRateFormDraft',
CLOSE_ACCOUNT_FORM: 'closeAccount',
@@ -411,6 +413,8 @@ const ONYXKEYS = {
EXIT_SURVEY_RESPONSE_FORM_DRAFT: 'exitSurveyResponseFormDraft',
POLICY_TAG_NAME_FORM: 'policyTagNameForm',
POLICY_TAG_NAME_FORM_DRAFT: 'policyTagNameFormDraft',
+ WORKSPACE_NEW_TAX_FORM: 'workspaceNewTaxForm',
+ WORKSPACE_NEW_TAX_FORM_DRAFT: 'workspaceNewTaxFormDraft',
},
} as const;
@@ -422,6 +426,7 @@ type OnyxFormValuesMapping = {
[ONYXKEYS.FORMS.WORKSPACE_CATEGORY_FORM]: FormTypes.WorkspaceCategoryForm;
[ONYXKEYS.FORMS.WORKSPACE_TAG_CREATE_FORM]: FormTypes.WorkspaceTagCreateForm;
[ONYXKEYS.FORMS.WORKSPACE_RATE_AND_UNIT_FORM]: FormTypes.WorkspaceRateAndUnitForm;
+ [ONYXKEYS.FORMS.WORKSPACE_TAX_CUSTOM_NAME]: FormTypes.WorkspaceTaxCustomName;
[ONYXKEYS.FORMS.CLOSE_ACCOUNT_FORM]: FormTypes.CloseAccountForm;
[ONYXKEYS.FORMS.PROFILE_SETTINGS_FORM]: FormTypes.ProfileSettingsForm;
[ONYXKEYS.FORMS.DISPLAY_NAME_FORM]: FormTypes.DisplayNameForm;
@@ -458,6 +463,7 @@ type OnyxFormValuesMapping = {
[ONYXKEYS.FORMS.PERSONAL_BANK_ACCOUNT]: FormTypes.PersonalBankAccountForm;
[ONYXKEYS.FORMS.WORKSPACE_DESCRIPTION_FORM]: FormTypes.WorkspaceDescriptionForm;
[ONYXKEYS.FORMS.POLICY_TAG_NAME_FORM]: FormTypes.PolicyTagNameForm;
+ [ONYXKEYS.FORMS.WORKSPACE_NEW_TAX_FORM]: FormTypes.WorkspaceNewTaxForm;
[ONYXKEYS.FORMS.POLICY_CREATE_DISTANCE_RATE_FORM]: FormTypes.PolicyCreateDistanceRateForm;
};
diff --git a/src/ROUTES.ts b/src/ROUTES.ts
index 7dc6785f444f..5769b60a8284 100644
--- a/src/ROUTES.ts
+++ b/src/ROUTES.ts
@@ -584,6 +584,22 @@ const ROUTES = {
route: 'settings/workspaces/:policyID/taxes',
getRoute: (policyID: string) => `settings/workspaces/${policyID}/taxes` as const,
},
+ WORKSPACE_TAXES_SETTINGS: {
+ route: 'settings/workspaces/:policyID/taxes/settings',
+ getRoute: (policyID: string) => `settings/workspaces/${policyID}/taxes/settings` as const,
+ },
+ WORKSPACE_TAXES_SETTINGS_WORKSPACE_CURRENCY_DEFAULT: {
+ route: 'settings/workspaces/:policyID/taxes/settings/workspace-currency',
+ getRoute: (policyID: string) => `settings/workspaces/${policyID}/taxes/settings/workspace-currency` as const,
+ },
+ WORKSPACE_TAXES_SETTINGS_FOREIGN_CURRENCY_DEFAULT: {
+ route: 'settings/workspaces/:policyID/taxes/settings/foreign-currency',
+ getRoute: (policyID: string) => `settings/workspaces/${policyID}/taxes/settings/foreign-currency` as const,
+ },
+ WORKSPACE_TAXES_SETTINGS_CUSTOM_TAX_NAME: {
+ route: 'settings/workspaces/:policyID/taxes/settings/tax-name',
+ getRoute: (policyID: string) => `settings/workspaces/${policyID}/taxes/settings/tax-name` as const,
+ },
WORKSPACE_MEMBER_DETAILS: {
route: 'settings/workspaces/:policyID/members/:accountID',
getRoute: (policyID: string, accountID: number, backTo?: string) => getUrlWithBackToParam(`settings/workspaces/${policyID}/members/${accountID}`, backTo),
@@ -592,6 +608,10 @@ const ROUTES = {
route: 'settings/workspaces/:policyID/members/:accountID/role-selection',
getRoute: (policyID: string, accountID: number, backTo?: string) => getUrlWithBackToParam(`settings/workspaces/${policyID}/members/${accountID}/role-selection`, backTo),
},
+ WORKSPACE_TAX_CREATE: {
+ route: 'settings/workspaces/:policyID/taxes/new',
+ getRoute: (policyID: string) => `settings/workspaces/${policyID}/taxes/new` as const,
+ },
WORKSPACE_DISTANCE_RATES: {
route: 'settings/workspaces/:policyID/distance-rates',
getRoute: (policyID: string) => `settings/workspaces/${policyID}/distance-rates` as const,
diff --git a/src/SCREENS.ts b/src/SCREENS.ts
index 34c7212e7f31..2fbd122f9972 100644
--- a/src/SCREENS.ts
+++ b/src/SCREENS.ts
@@ -216,6 +216,11 @@ const SCREENS = {
TAGS_SETTINGS: 'Tags_Settings',
TAGS_EDIT: 'Tags_Edit',
TAXES: 'Workspace_Taxes',
+ TAXES_SETTINGS: 'Workspace_Taxes_Settings',
+ TAXES_SETTINGS_CUSTOM_TAX_NAME: 'Workspace_Taxes_Settings_CustomTaxName',
+ TAXES_SETTINGS_WORKSPACE_CURRENCY_DEFAULT: 'Workspace_Taxes_Settings_WorkspaceCurrency',
+ TAXES_SETTINGS_FOREIGN_CURRENCY_DEFAULT: 'Workspace_Taxes_Settings_ForeignCurrency',
+ TAX_CREATE: 'Workspace_Tax_Create',
TAG_CREATE: 'Tag_Create',
TAG_SETTINGS: 'Tag_Settings',
CURRENCY: 'Workspace_Profile_Currency',
diff --git a/src/components/AmountForm.tsx b/src/components/AmountForm.tsx
index 8ae8f0674012..48035dd884bd 100644
--- a/src/components/AmountForm.tsx
+++ b/src/components/AmountForm.tsx
@@ -12,8 +12,9 @@ import * as MoneyRequestUtils from '@libs/MoneyRequestUtils';
import CONST from '@src/CONST';
import BigNumberPad from './BigNumberPad';
import FormHelpMessage from './FormHelpMessage';
-import type {BaseTextInputRef} from './TextInput/BaseTextInput/types';
+import type {BaseTextInputProps, BaseTextInputRef} from './TextInput/BaseTextInput/types';
import TextInputWithCurrencySymbol from './TextInputWithCurrencySymbol';
+import type TextInputWithCurrencySymbolProps from './TextInputWithCurrencySymbol/types';
type AmountFormProps = {
/** Amount supplied by the FormProvider */
@@ -36,7 +37,8 @@ type AmountFormProps = {
/** Whether the currency symbol is pressable */
isCurrencyPressable?: boolean;
-};
+} & Pick &
+ Pick;
/**
* Returns the new selection object based on the updated amount's length
@@ -51,7 +53,7 @@ const NUM_PAD_CONTAINER_VIEW_ID = 'numPadContainerView';
const NUM_PAD_VIEW_ID = 'numPadView';
function AmountForm(
- {value: amount, currency = CONST.CURRENCY.USD, extraDecimals = 0, errorText, onInputChange, onCurrencyButtonPress, isCurrencyPressable = true}: AmountFormProps,
+ {value: amount, currency = CONST.CURRENCY.USD, extraDecimals = 0, errorText, onInputChange, onCurrencyButtonPress, isCurrencyPressable = true, ...rest}: AmountFormProps,
forwardedRef: ForwardedRef,
) {
const styles = useThemeStyles();
@@ -214,6 +216,8 @@ function AmountForm(
}}
onKeyPress={textInputKeyPress}
isCurrencyPressable={isCurrencyPressable}
+ // eslint-disable-next-line react/jsx-props-no-spreading
+ {...rest}
/>
{!!errorText && (
+
+
+
+
+
+
+
+
+
+ );
+}
+
+AmountSelectorModal.displayName = 'AmountSelectorModal';
+
+export default AmountSelectorModal;
diff --git a/src/components/AmountPicker/index.tsx b/src/components/AmountPicker/index.tsx
new file mode 100644
index 000000000000..701c75175c02
--- /dev/null
+++ b/src/components/AmountPicker/index.tsx
@@ -0,0 +1,65 @@
+import React, {forwardRef, useState} from 'react';
+import type {ForwardedRef} from 'react';
+import {View} from 'react-native';
+import FormHelpMessage from '@components/FormHelpMessage';
+import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription';
+import useStyleUtils from '@hooks/useStyleUtils';
+import useThemeStyles from '@hooks/useThemeStyles';
+import variables from '@styles/variables';
+import callOrReturn from '@src/types/utils/callOrReturn';
+import AmountSelectorModal from './AmountSelectorModal';
+import type {AmountPickerProps} from './types';
+
+function AmountPicker({value, description, title, errorText = '', onInputChange, furtherDetails, rightLabel, ...rest}: AmountPickerProps, forwardedRef: ForwardedRef) {
+ const styles = useThemeStyles();
+ const StyleUtils = useStyleUtils();
+ const [isPickerVisible, setIsPickerVisible] = useState(false);
+
+ const showPickerModal = () => {
+ setIsPickerVisible(true);
+ };
+
+ const hidePickerModal = () => {
+ setIsPickerVisible(false);
+ };
+
+ const updateInput = (updatedValue: string) => {
+ if (updatedValue !== value) {
+ onInputChange?.(updatedValue);
+ }
+ hidePickerModal();
+ };
+
+ const descStyle = !value || value.length === 0 ? StyleUtils.getFontSizeStyle(variables.fontSizeLabel) : null;
+
+ return (
+
+
+
+
+
+
+
+ );
+}
+
+AmountPicker.displayName = 'AmountPicker';
+
+export default forwardRef(AmountPicker);
diff --git a/src/components/AmountPicker/types.ts b/src/components/AmountPicker/types.ts
new file mode 100644
index 000000000000..f7025685d840
--- /dev/null
+++ b/src/components/AmountPicker/types.ts
@@ -0,0 +1,40 @@
+import type {AmountFormProps} from '@components/AmountForm';
+import type {MenuItemBaseProps} from '@components/MenuItem';
+import type {MaybePhraseKey} from '@libs/Localize';
+
+type AmountSelectorModalProps = {
+ /** Whether the modal is visible */
+ isVisible: boolean;
+
+ /** Current value */
+ value?: string;
+
+ /** Function to call when the user selects a item */
+ onValueSelected?: (value: string) => void;
+
+ /** Function to call when the user closes the modal */
+ onClose: () => void;
+} & Pick;
+
+type AmountPickerProps = {
+ /** Item to display */
+ value?: string;
+
+ /** A placeholder value to display */
+ title?: string | ((value?: string) => string);
+
+ /** Form Error description */
+ errorText?: MaybePhraseKey;
+
+ /** Callback to call when the input changes */
+ onInputChange?: (value: string | undefined) => void;
+
+ /** Text to display under the main menu item */
+ furtherDetails?: string;
+
+ /** Whether to show the tooltip text */
+ shouldShowTooltips?: boolean;
+} & Pick &
+ AmountFormProps;
+
+export type {AmountSelectorModalProps, AmountPickerProps};
diff --git a/src/components/AmountTextInput.tsx b/src/components/AmountTextInput.tsx
index aff3ff5b5e39..6842a3e1d335 100644
--- a/src/components/AmountTextInput.tsx
+++ b/src/components/AmountTextInput.tsx
@@ -5,7 +5,7 @@ import useThemeStyles from '@hooks/useThemeStyles';
import CONST from '@src/CONST';
import type {TextSelection} from './Composer/types';
import TextInput from './TextInput';
-import type {BaseTextInputRef} from './TextInput/BaseTextInput/types';
+import type {BaseTextInputProps, BaseTextInputRef} from './TextInput/BaseTextInput/types';
type AmountTextInputProps = {
/** Formatted amount in local currency */
@@ -31,10 +31,10 @@ type AmountTextInputProps = {
/** Function to call to handle key presses in the text input */
onKeyPress?: (event: NativeSyntheticEvent) => void;
-};
+} & Pick;
function AmountTextInput(
- {formattedAmount, onChangeAmount, placeholder, selection, onSelectionChange, style, touchableInputWrapperStyle, onKeyPress}: AmountTextInputProps,
+ {formattedAmount, onChangeAmount, placeholder, selection, onSelectionChange, style, touchableInputWrapperStyle, onKeyPress, ...rest}: AmountTextInputProps,
ref: ForwardedRef,
) {
const styles = useThemeStyles();
@@ -57,6 +57,8 @@ function AmountTextInput(
role={CONST.ROLE.PRESENTATION}
onKeyPress={onKeyPress as (event: NativeSyntheticEvent) => void}
touchableInputWrapperStyle={touchableInputWrapperStyle}
+ // eslint-disable-next-line react/jsx-props-no-spreading
+ {...rest}
/>
);
}
diff --git a/src/components/FlatList/index.android.tsx b/src/components/FlatList/index.android.tsx
index 863930203863..1246367d29e8 100644
--- a/src/components/FlatList/index.android.tsx
+++ b/src/components/FlatList/index.android.tsx
@@ -1,7 +1,7 @@
import {useFocusEffect} from '@react-navigation/native';
import type {ForwardedRef} from 'react';
import React, {forwardRef, useCallback, useContext} from 'react';
-import type {FlatListProps} from 'react-native';
+import type {FlatListProps, NativeScrollEvent, NativeSyntheticEvent} from 'react-native';
import {FlatList} from 'react-native';
import {ActionListContext} from '@pages/home/ReportScreenContext';
@@ -22,6 +22,9 @@ function CustomFlatList(props: FlatListProps, ref: ForwardedRef)
}
}, [scrollPosition?.offset, ref]);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ const onMomentumScrollEnd = useCallback((event: NativeSyntheticEvent) => setScrollPosition({offset: event.nativeEvent.contentOffset.y}), []);
+
useFocusEffect(
useCallback(() => {
onScreenFocus();
@@ -32,10 +35,8 @@ function CustomFlatList(props: FlatListProps, ref: ForwardedRef)
// eslint-disable-next-line react/jsx-props-no-spreading
{...props}
- onScroll={(event) => props.onScroll?.(event)}
- onMomentumScrollEnd={(event) => {
- setScrollPosition({offset: event.nativeEvent.contentOffset.y});
- }}
+ onScroll={props.onScroll}
+ onMomentumScrollEnd={onMomentumScrollEnd}
ref={ref}
/>
);
diff --git a/src/components/FloatingActionButton.tsx b/src/components/FloatingActionButton.tsx
index b9c52ad397ec..ee61beda74ae 100644
--- a/src/components/FloatingActionButton.tsx
+++ b/src/components/FloatingActionButton.tsx
@@ -1,8 +1,8 @@
import type {ForwardedRef} from 'react';
import React, {forwardRef, useEffect, useRef} from 'react';
// eslint-disable-next-line no-restricted-imports
-import type {GestureResponderEvent, Role, Text} from 'react-native';
-import {Platform, View} from 'react-native';
+import type {GestureResponderEvent, Role, Text, View} from 'react-native';
+import {Platform} from 'react-native';
import Animated, {createAnimatedPropAdapter, Easing, interpolateColor, processColor, useAnimatedProps, useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated';
import Svg, {Path} from 'react-native-svg';
import useLocalize from '@hooks/useLocalize';
@@ -10,15 +10,11 @@ import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
import variables from '@styles/variables';
import {PressableWithoutFeedback} from './Pressable';
-import PressableWithFeedback from './Pressable/PressableWithFeedback';
import Tooltip from './Tooltip/PopoverAnchorTooltip';
const AnimatedPath = Animated.createAnimatedComponent(Path);
AnimatedPath.displayName = 'AnimatedPath';
-const AnimatedPressable = Animated.createAnimatedComponent(PressableWithFeedback);
-AnimatedPressable.displayName = 'AnimatedPressable';
-
type AdapterPropsRecord = {
type: number;
payload?: number | null;
@@ -104,41 +100,34 @@ function FloatingActionButton({onPress, isActive, accessibilityLabel, role}: Flo
};
return (
-
-
-
- {
- fabPressable.current = el ?? null;
- if (buttonRef && 'current' in buttonRef) {
- buttonRef.current = el ?? null;
- }
- }}
- accessibilityLabel={accessibilityLabel}
- role={role}
- pressDimmingValue={1}
- onPress={toggleFabAction}
- onLongPress={() => {}}
- shouldUseHapticsOnLongPress={false}
- style={[styles.floatingActionButton, animatedStyle]}
+
+ {
+ fabPressable.current = el ?? null;
+ if (buttonRef && 'current' in buttonRef) {
+ buttonRef.current = el ?? null;
+ }
+ }}
+ style={[styles.h100, styles.bottomTabBarItem]}
+ accessibilityLabel={accessibilityLabel}
+ onPress={toggleFabAction}
+ onLongPress={() => {}}
+ role={role}
+ shouldUseHapticsOnLongPress={false}
+ >
+
+
-
-
-
+
+
+
+
+
);
}
diff --git a/src/components/Form/types.ts b/src/components/Form/types.ts
index 8df5c0287d3b..7b50bf31aa86 100644
--- a/src/components/Form/types.ts
+++ b/src/components/Form/types.ts
@@ -4,6 +4,7 @@ import type {ValueOf} from 'type-fest';
import type AddPlaidBankAccount from '@components/AddPlaidBankAccount';
import type AddressSearch from '@components/AddressSearch';
import type AmountForm from '@components/AmountForm';
+import type AmountPicker from '@components/AmountPicker';
import type AmountTextInput from '@components/AmountTextInput';
import type CheckboxWithLabel from '@components/CheckboxWithLabel';
import type CountrySelector from '@components/CountrySelector';
@@ -14,6 +15,7 @@ import type RoomNameInput from '@components/RoomNameInput';
import type SingleChoiceQuestion from '@components/SingleChoiceQuestion';
import type StatePicker from '@components/StatePicker';
import type TextInput from '@components/TextInput';
+import type TextPicker from '@components/TextPicker';
import type ValuePicker from '@components/ValuePicker';
import type {MaybePhraseKey} from '@libs/Localize';
import type BusinessTypePicker from '@pages/ReimbursementAccount/BusinessInfo/substeps/TypeBusiness/BusinessTypePicker';
@@ -43,6 +45,8 @@ type ValidInputs =
| typeof ValuePicker
| typeof DatePicker
| typeof RadioButtons
+ | typeof AmountPicker
+ | typeof TextPicker
| typeof AddPlaidBankAccount;
type ValueTypeKey = 'string' | 'boolean' | 'date' | 'country';
diff --git a/src/components/HeaderWithBackButton/types.ts b/src/components/HeaderWithBackButton/types.ts
index 243b3c3986ad..3b7da9f77287 100644
--- a/src/components/HeaderWithBackButton/types.ts
+++ b/src/components/HeaderWithBackButton/types.ts
@@ -1,10 +1,10 @@
import type {ReactNode} from 'react';
import type {StyleProp, ViewStyle} from 'react-native';
-import type {OnyxCollection, OnyxEntry} from 'react-native-onyx';
+import type {OnyxEntry} from 'react-native-onyx';
import type {Action} from '@hooks/useSingleExecution';
import type {StepCounterParams} from '@src/languages/types';
import type {AnchorPosition} from '@src/styles';
-import type {PersonalDetails, Policy, Report} from '@src/types/onyx';
+import type {Policy, Report} from '@src/types/onyx';
import type {Icon} from '@src/types/onyx/OnyxCommon';
import type ChildrenProps from '@src/types/utils/ChildrenProps';
import type IconAsset from '@src/types/utils/IconAsset';
@@ -103,9 +103,6 @@ type HeaderWithBackButtonProps = Partial & {
/** The report's policy, if we're showing the details for a report and need info about it for AvatarWithDisplay */
policy?: OnyxEntry;
- /** Policies, if we're showing the details for a report and need participant details for AvatarWithDisplay */
- personalDetails?: OnyxCollection;
-
/** Single execution function to prevent concurrent navigation actions */
singleExecution?: (action: Action) => Action;
diff --git a/src/components/InvertedFlatList/BaseInvertedFlatList.tsx b/src/components/InvertedFlatList/BaseInvertedFlatList.tsx
index e28400505280..0549e19c2eb4 100644
--- a/src/components/InvertedFlatList/BaseInvertedFlatList.tsx
+++ b/src/components/InvertedFlatList/BaseInvertedFlatList.tsx
@@ -6,6 +6,11 @@ import FlatList from '@components/FlatList';
const WINDOW_SIZE = 15;
const AUTOSCROLL_TO_TOP_THRESHOLD = 128;
+const maintainVisibleContentPosition = {
+ minIndexForVisible: 0,
+ autoscrollToTopThreshold: AUTOSCROLL_TO_TOP_THRESHOLD,
+};
+
function BaseInvertedFlatList(props: FlatListProps, ref: ForwardedRef) {
return (
(props: FlatListProps, ref: ForwardedRef
);
diff --git a/src/components/LHNOptionsList/LHNOptionsList.tsx b/src/components/LHNOptionsList/LHNOptionsList.tsx
index 27f424ad1b70..fa4c89216d08 100644
--- a/src/components/LHNOptionsList/LHNOptionsList.tsx
+++ b/src/components/LHNOptionsList/LHNOptionsList.tsx
@@ -1,9 +1,8 @@
import {FlashList} from '@shopify/flash-list';
import type {ReactElement} from 'react';
-import React, {memo, useCallback} from 'react';
+import React, {memo, useCallback, useMemo} from 'react';
import {StyleSheet, View} from 'react-native';
import {withOnyx} from 'react-native-onyx';
-import withCurrentReportID from '@components/withCurrentReportID';
import usePermissions from '@hooks/usePermissions';
import useThemeStyles from '@hooks/useThemeStyles';
import * as ReportActionsUtils from '@libs/ReportActionsUtils';
@@ -28,7 +27,6 @@ function LHNOptionsList({
preferredLocale = CONST.LOCALES.DEFAULT,
personalDetails = {},
transactions = {},
- currentReportID = '',
draftComments = {},
transactionViolations = {},
onFirstItemRendered = () => {},
@@ -84,7 +82,7 @@ function LHNOptionsList({
lastReportActionTransaction={lastReportActionTransaction}
receiptTransactions={transactions}
viewMode={optionMode}
- isFocused={!shouldDisableFocusOptions && reportID === currentReportID}
+ isFocused={!shouldDisableFocusOptions}
onSelectRow={onSelectRow}
preferredLocale={preferredLocale}
comment={itemComment}
@@ -95,7 +93,6 @@ function LHNOptionsList({
);
},
[
- currentReportID,
draftComments,
onSelectRow,
optionMode,
@@ -112,6 +109,8 @@ function LHNOptionsList({
],
);
+ const extraData = useMemo(() => [reportActions, reports, policy, personalDetails], [reportActions, reports, policy, personalDetails]);
+
return (
@@ -132,33 +131,31 @@ function LHNOptionsList({
LHNOptionsList.displayName = 'LHNOptionsList';
-export default withCurrentReportID(
- withOnyx({
- reports: {
- key: ONYXKEYS.COLLECTION.REPORT,
- },
- reportActions: {
- key: ONYXKEYS.COLLECTION.REPORT_ACTIONS,
- },
- policy: {
- key: ONYXKEYS.COLLECTION.POLICY,
- },
- preferredLocale: {
- key: ONYXKEYS.NVP_PREFERRED_LOCALE,
- },
- personalDetails: {
- key: ONYXKEYS.PERSONAL_DETAILS_LIST,
- },
- transactions: {
- key: ONYXKEYS.COLLECTION.TRANSACTION,
- },
- draftComments: {
- key: ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT,
- },
- transactionViolations: {
- key: ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS,
- },
- })(memo(LHNOptionsList)),
-);
+export default withOnyx({
+ reports: {
+ key: ONYXKEYS.COLLECTION.REPORT,
+ },
+ reportActions: {
+ key: ONYXKEYS.COLLECTION.REPORT_ACTIONS,
+ },
+ policy: {
+ key: ONYXKEYS.COLLECTION.POLICY,
+ },
+ preferredLocale: {
+ key: ONYXKEYS.NVP_PREFERRED_LOCALE,
+ },
+ personalDetails: {
+ key: ONYXKEYS.PERSONAL_DETAILS_LIST,
+ },
+ transactions: {
+ key: ONYXKEYS.COLLECTION.TRANSACTION,
+ },
+ draftComments: {
+ key: ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT,
+ },
+ transactionViolations: {
+ key: ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS,
+ },
+})(memo(LHNOptionsList));
export type {LHNOptionsListProps};
diff --git a/src/components/LHNOptionsList/OptionRowLHNData.tsx b/src/components/LHNOptionsList/OptionRowLHNData.tsx
index a18d5a8ec1ec..0db8e581e23e 100644
--- a/src/components/LHNOptionsList/OptionRowLHNData.tsx
+++ b/src/components/LHNOptionsList/OptionRowLHNData.tsx
@@ -1,5 +1,6 @@
import {deepEqual} from 'fast-equals';
import React, {useEffect, useMemo, useRef} from 'react';
+import useCurrentReportID from '@hooks/useCurrentReportID';
import * as ReportUtils from '@libs/ReportUtils';
import SidebarUtils from '@libs/SidebarUtils';
import * as Report from '@userActions/Report';
@@ -31,6 +32,8 @@ function OptionRowLHNData({
...propsToForward
}: OptionRowLHNDataProps) {
const reportID = propsToForward.reportID;
+ const currentReportIDValue = useCurrentReportID();
+ const isReportFocused = isFocused && currentReportIDValue?.currentReportID === reportID;
const optionItemRef = useRef();
@@ -83,7 +86,7 @@ function OptionRowLHNData({
);
diff --git a/src/components/LHNOptionsList/types.ts b/src/components/LHNOptionsList/types.ts
index 58bea97f04c9..efc1c63233b5 100644
--- a/src/components/LHNOptionsList/types.ts
+++ b/src/components/LHNOptionsList/types.ts
@@ -3,7 +3,6 @@ import type {RefObject} from 'react';
import type {LayoutChangeEvent, StyleProp, TextStyle, View, ViewStyle} from 'react-native';
import type {OnyxCollection, OnyxEntry} from 'react-native-onyx';
import type {ValueOf} from 'type-fest';
-import type {CurrentReportIDContextValue} from '@components/withCurrentReportID';
import type CONST from '@src/CONST';
import type {OptionData} from '@src/libs/ReportUtils';
import type {Locale, PersonalDetailsList, Policy, Report, ReportAction, ReportActions, Transaction, TransactionViolation} from '@src/types/onyx';
@@ -60,7 +59,7 @@ type CustomLHNOptionsListProps = {
onFirstItemRendered: () => void;
};
-type LHNOptionsListProps = CustomLHNOptionsListProps & CurrentReportIDContextValue & LHNOptionsListOnyxProps;
+type LHNOptionsListProps = CustomLHNOptionsListProps & LHNOptionsListOnyxProps;
type OptionRowLHNDataProps = {
/** Whether row should be focused */
diff --git a/src/components/Lightbox/index.tsx b/src/components/Lightbox/index.tsx
index 70325ff68a70..56852a8e2ea1 100644
--- a/src/components/Lightbox/index.tsx
+++ b/src/components/Lightbox/index.tsx
@@ -9,11 +9,9 @@ import MultiGestureCanvas, {DEFAULT_ZOOM_RANGE} from '@components/MultiGestureCa
import type {CanvasSize, ContentSize, OnScaleChangedCallback, ZoomRange} from '@components/MultiGestureCanvas/types';
import {getCanvasFitScale} from '@components/MultiGestureCanvas/utils';
import useStyleUtils from '@hooks/useStyleUtils';
+import useThemeStyles from '@hooks/useThemeStyles';
import NUMBER_OF_CONCURRENT_LIGHTBOXES from './numberOfConcurrentLightboxes';
-const DEFAULT_IMAGE_SIZE = 200;
-const DEFAULT_IMAGE_DIMENSION: ContentSize = {width: DEFAULT_IMAGE_SIZE, height: DEFAULT_IMAGE_SIZE};
-
const cachedImageDimensions = new Map();
type LightboxProps = {
@@ -41,6 +39,7 @@ type LightboxProps = {
*/
function Lightbox({isAuthTokenRequired = false, uri, onScaleChanged: onScaleChangedProp, onError, style, zoomRange = DEFAULT_ZOOM_RANGE}: LightboxProps) {
const StyleUtils = useStyleUtils();
+ const styles = useThemeStyles();
/**
* React hooks must be used in the render function of the component at top-level and unconditionally.
@@ -137,7 +136,7 @@ function Lightbox({isAuthTokenRequired = false, uri, onScaleChanged: onScaleChan
const [isFallbackImageLoaded, setFallbackImageLoaded] = useState(false);
const fallbackSize = useMemo(() => {
if (!hasSiblingCarouselItems || !contentSize || isCanvasLoading) {
- return DEFAULT_IMAGE_DIMENSION;
+ return undefined;
}
const {minScale} = getCanvasFitScale({canvasSize, contentSize});
@@ -217,7 +216,7 @@ function Lightbox({isAuthTokenRequired = false, uri, onScaleChanged: onScaleChan
>
setFallbackImageLoaded(true)}
diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx
index 74fec2c606af..2bf346ec8de4 100644
--- a/src/components/MoneyReportHeader.tsx
+++ b/src/components/MoneyReportHeader.tsx
@@ -20,7 +20,6 @@ import ConfirmModal from './ConfirmModal';
import HeaderWithBackButton from './HeaderWithBackButton';
import * as Expensicons from './Icon/Expensicons';
import MoneyReportHeaderStatusBar from './MoneyReportHeaderStatusBar';
-import {usePersonalDetails} from './OnyxProvider';
import SettlementButton from './SettlementButton';
type PaymentType = DeepValueOf;
@@ -45,7 +44,6 @@ type MoneyReportHeaderProps = MoneyReportHeaderOnyxProps & {
};
function MoneyReportHeader({session, policy, chatReport, nextStep, report: moneyRequestReport}: MoneyReportHeaderProps) {
- const personalDetails = usePersonalDetails() || CONST.EMPTY_OBJECT;
const styles = useThemeStyles();
const {translate} = useLocalize();
const {windowWidth, isSmallScreenWidth} = useWindowDimensions();
@@ -102,7 +100,6 @@ function MoneyReportHeader({session, policy, chatReport, nextStep, report: money
shouldShowPinButton={false}
report={moneyRequestReport}
policy={policy}
- personalDetails={personalDetails}
shouldShowBackButton={isSmallScreenWidth}
onBackButtonPress={() => Navigation.goBack(undefined, false, true)}
// Shows border if no buttons or next steps are showing below the header
diff --git a/src/components/MoneyRequestHeader.tsx b/src/components/MoneyRequestHeader.tsx
index a9304b9c3138..e70e121569fd 100644
--- a/src/components/MoneyRequestHeader.tsx
+++ b/src/components/MoneyRequestHeader.tsx
@@ -21,7 +21,6 @@ import HeaderWithBackButton from './HeaderWithBackButton';
import HoldBanner from './HoldBanner';
import * as Expensicons from './Icon/Expensicons';
import MoneyRequestHeaderStatusBar from './MoneyRequestHeaderStatusBar';
-import {usePersonalDetails} from './OnyxProvider';
import ProcessMoneyRequestHoldMenu from './ProcessMoneyRequestHoldMenu';
type MoneyRequestHeaderOnyxProps = {
@@ -54,7 +53,6 @@ type MoneyRequestHeaderProps = MoneyRequestHeaderOnyxProps & {
};
function MoneyRequestHeader({session, parentReport, report, parentReportAction, transaction, shownHoldUseExplanation = false, policy}: MoneyRequestHeaderProps) {
- const personalDetails = usePersonalDetails() || CONST.EMPTY_OBJECT;
const styles = useThemeStyles();
const {translate} = useLocalize();
const [isDeleteModalVisible, setIsDeleteModalVisible] = useState(false);
@@ -173,7 +171,6 @@ function MoneyRequestHeader({session, parentReport, report, parentReportAction,
ownerAccountID: parentReport?.ownerAccountID,
}}
policy={policy}
- personalDetails={personalDetails}
shouldShowBackButton={isSmallScreenWidth}
onBackButtonPress={() => Navigation.goBack(undefined, false, true)}
/>
diff --git a/src/components/MultipleAvatars.tsx b/src/components/MultipleAvatars.tsx
index ba1a5351117b..98bc47e41bbe 100644
--- a/src/components/MultipleAvatars.tsx
+++ b/src/components/MultipleAvatars.tsx
@@ -46,7 +46,7 @@ type MultipleAvatarsProps = {
/** Whether avatars are displayed within a reportAction */
isInReportAction?: boolean;
- /** Whether to show the toolip text */
+ /** Whether to show the tooltip text */
shouldShowTooltip?: boolean;
/** Whether avatars are displayed with the highlighted background color instead of the app background color. This is primarily the case for IOU previews. */
diff --git a/src/components/SelectionList/BaseListItem.tsx b/src/components/SelectionList/BaseListItem.tsx
index 303b90a682e6..4c1f208ce11d 100644
--- a/src/components/SelectionList/BaseListItem.tsx
+++ b/src/components/SelectionList/BaseListItem.tsx
@@ -71,7 +71,7 @@ function BaseListItem({
accessibilityLabel={item.text ?? ''}
role={CONST.ROLE.BUTTON}
hoverDimmingValue={1}
- hoverStyle={!item.isSelected && styles.hoveredComponentBG}
+ hoverStyle={!item.isDisabled && !item.isSelected && styles.hoveredComponentBG}
dataSet={{[CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT]: true}}
onMouseDown={shouldPreventDefaultFocusOnSelectRow ? (e) => e.preventDefault() : undefined}
nativeID={keyForList ?? ''}
diff --git a/src/components/SelectionList/RadioListItem.tsx b/src/components/SelectionList/RadioListItem.tsx
index 1c5484772dbf..27f7fc7ef803 100644
--- a/src/components/SelectionList/RadioListItem.tsx
+++ b/src/components/SelectionList/RadioListItem.tsx
@@ -51,6 +51,7 @@ function RadioListItem({
styles.sidebarLinkTextBold,
isMultilineSupported ? styles.preWrap : styles.pre,
item.alternateText ? styles.mb1 : null,
+ isDisabled && styles.colorMuted,
]}
numberOfLines={isMultilineSupported ? 2 : 1}
/>
diff --git a/src/components/SelectionList/selectionListPropTypes.js b/src/components/SelectionList/selectionListPropTypes.js
index f5178112a4c3..45ed1a865d33 100644
--- a/src/components/SelectionList/selectionListPropTypes.js
+++ b/src/components/SelectionList/selectionListPropTypes.js
@@ -190,7 +190,7 @@ const propTypes = {
/** Custom content to display in the footer */
footerContent: PropTypes.oneOfType([PropTypes.func, PropTypes.node]),
- /** Whether to show the toolip text */
+ /** Whether to show the tooltip text */
shouldShowTooltips: PropTypes.bool,
/** Whether to use dynamic maxToRenderPerBatch depending on the visible number of elements */
diff --git a/src/components/Switch.tsx b/src/components/Switch.tsx
index fd9d9ae315ff..684d5e416471 100644
--- a/src/components/Switch.tsx
+++ b/src/components/Switch.tsx
@@ -14,6 +14,9 @@ type SwitchProps = {
/** Accessibility label for the switch */
accessibilityLabel: string;
+
+ /** Whether the switch is disabled */
+ disabled?: boolean;
};
const OFFSET_X = {
@@ -21,7 +24,7 @@ const OFFSET_X = {
ON: 20,
};
-function Switch({isOn, onToggle, accessibilityLabel}: SwitchProps) {
+function Switch({isOn, onToggle, accessibilityLabel, disabled}: SwitchProps) {
const styles = useThemeStyles();
const offsetX = useRef(new Animated.Value(isOn ? OFFSET_X.ON : OFFSET_X.OFF));
@@ -35,6 +38,7 @@ function Switch({isOn, onToggle, accessibilityLabel}: SwitchProps) {
return (
onToggle(!isOn)}
onLongPress={() => onToggle(!isOn)}
diff --git a/src/components/TaxPicker.tsx b/src/components/TaxPicker.tsx
index 664aa741c400..dad7117bef67 100644
--- a/src/components/TaxPicker.tsx
+++ b/src/components/TaxPicker.tsx
@@ -1,17 +1,18 @@
-import React, {useMemo, useState} from 'react';
+import React, {useCallback, useMemo, useState} from 'react';
import type {EdgeInsets} from 'react-native-safe-area-context';
import useLocalize from '@hooks/useLocalize';
import useStyleUtils from '@hooks/useStyleUtils';
-import useThemeStyles from '@hooks/useThemeStyles';
import * as OptionsListUtils from '@libs/OptionsListUtils';
import * as TransactionUtils from '@libs/TransactionUtils';
import CONST from '@src/CONST';
import type {TaxRatesWithDefault} from '@src/types/onyx';
-import OptionsSelector from './OptionsSelector';
+import SelectionList from './SelectionList';
+import RadioListItem from './SelectionList/RadioListItem';
+import type {ListItem} from './SelectionList/types';
type TaxPickerProps = {
/** Collection of tax rates attached to a policy */
- taxRates: TaxRatesWithDefault;
+ taxRates?: TaxRatesWithDefault;
/** The selected tax rate of an expense */
selectedTaxRate?: string;
@@ -23,20 +24,21 @@ type TaxPickerProps = {
insets?: EdgeInsets;
/** Callback to fire when a tax is pressed */
- onSubmit: () => void;
+ onSubmit: (tax: ListItem) => void;
};
function TaxPicker({selectedTaxRate = '', taxRates, insets, onSubmit}: TaxPickerProps) {
- const styles = useThemeStyles();
const StyleUtils = useStyleUtils();
const {translate} = useLocalize();
const [searchValue, setSearchValue] = useState('');
- const taxRatesCount = TransactionUtils.getEnabledTaxRateCount(taxRates.taxes);
+ const taxRatesCount = TransactionUtils.getEnabledTaxRateCount(taxRates?.taxes ?? {});
const isTaxRatesCountBelowThreshold = taxRatesCount < CONST.TAX_RATES_LIST_THRESHOLD;
const shouldShowTextInput = !isTaxRatesCountBelowThreshold;
+ const getTaxName = useCallback((key: string) => taxRates?.taxes[key].name, [taxRates?.taxes]);
+
const selectedOptions = useMemo(() => {
if (!selectedTaxRate) {
return [];
@@ -44,39 +46,32 @@ function TaxPicker({selectedTaxRate = '', taxRates, insets, onSubmit}: TaxPicker
return [
{
- name: selectedTaxRate,
+ name: getTaxName(selectedTaxRate),
enabled: true,
accountID: null,
},
];
- }, [selectedTaxRate]);
+ }, [selectedTaxRate, getTaxName]);
const sections = useMemo(() => {
- const {taxRatesOptions} = OptionsListUtils.getFilteredOptions({}, {}, [], searchValue, selectedOptions, [], false, false, false, {}, [], false, {}, [], false, false, true, taxRates);
+ const taxRatesOptions = OptionsListUtils.getTaxRatesSection(taxRates, selectedOptions as OptionsListUtils.Category[], searchValue, selectedTaxRate);
return taxRatesOptions;
- }, [taxRates, searchValue, selectedOptions]);
+ }, [taxRates, searchValue, selectedOptions, selectedTaxRate]);
- const selectedOptionKey = sections?.[0]?.data?.find((taxRate) => taxRate.searchText === selectedTaxRate)?.keyForList;
+ const headerMessage = OptionsListUtils.getHeaderMessageForNonUserList(sections[0].data.length > 0, searchValue);
return (
-
);
}
diff --git a/src/components/TextInputWithCurrencySymbol/BaseTextInputWithCurrencySymbol.tsx b/src/components/TextInputWithCurrencySymbol/BaseTextInputWithCurrencySymbol.tsx
index b6509d5c3f47..5ea8d140c6a0 100644
--- a/src/components/TextInputWithCurrencySymbol/BaseTextInputWithCurrencySymbol.tsx
+++ b/src/components/TextInputWithCurrencySymbol/BaseTextInputWithCurrencySymbol.tsx
@@ -20,6 +20,9 @@ function BaseTextInputWithCurrencySymbol(
onSelectionChange = () => {},
onKeyPress = () => {},
isCurrencyPressable = true,
+ hideCurrencySymbol = false,
+ extraSymbol,
+ ...rest
}: TextInputWithCurrencySymbolProps,
ref: React.ForwardedRef,
) {
@@ -28,7 +31,7 @@ function BaseTextInputWithCurrencySymbol(
const isCurrencySymbolLTR = CurrencyUtils.isCurrencySymbolLTR(selectedCurrencyCode);
const styles = useThemeStyles();
- const currencySymbolButton = (
+ const currencySymbolButton = !hideCurrencySymbol && (
);
@@ -66,6 +71,7 @@ function BaseTextInputWithCurrencySymbol(
<>
{currencySymbolButton}
{amountTextInput}
+ {extraSymbol}
>
);
}
@@ -74,6 +80,7 @@ function BaseTextInputWithCurrencySymbol(
<>
{amountTextInput}
{currencySymbolButton}
+ {extraSymbol}
>
);
}
diff --git a/src/components/TextInputWithCurrencySymbol/types.ts b/src/components/TextInputWithCurrencySymbol/types.ts
index 5cd088a10edb..753d957221b2 100644
--- a/src/components/TextInputWithCurrencySymbol/types.ts
+++ b/src/components/TextInputWithCurrencySymbol/types.ts
@@ -1,5 +1,6 @@
import type {NativeSyntheticEvent, TextInputSelectionChangeEventData} from 'react-native';
import type {TextSelection} from '@components/Composer/types';
+import type {BaseTextInputProps} from '@components/TextInput/BaseTextInput/types';
type TextInputWithCurrencySymbolProps = {
/** Formatted amount in local currency */
@@ -28,6 +29,12 @@ type TextInputWithCurrencySymbolProps = {
/** Whether the currency symbol is pressable */
isCurrencyPressable: boolean;
-};
+
+ /** Whether to hide the currency symbol */
+ hideCurrencySymbol?: boolean;
+
+ /** Extra symbol to display */
+ extraSymbol?: React.ReactNode;
+} & Pick;
export default TextInputWithCurrencySymbolProps;
diff --git a/src/components/TextPicker/TextSelectorModal.tsx b/src/components/TextPicker/TextSelectorModal.tsx
new file mode 100644
index 000000000000..d7d621ff46cf
--- /dev/null
+++ b/src/components/TextPicker/TextSelectorModal.tsx
@@ -0,0 +1,63 @@
+import React, {useState} from 'react';
+import {View} from 'react-native';
+import Button from '@components/Button';
+import HeaderWithBackButton from '@components/HeaderWithBackButton';
+import Modal from '@components/Modal';
+import ScreenWrapper from '@components/ScreenWrapper';
+import ScrollView from '@components/ScrollView';
+import TextInput from '@components/TextInput';
+import useLocalize from '@hooks/useLocalize';
+import useThemeStyles from '@hooks/useThemeStyles';
+import CONST from '@src/CONST';
+import type {TextSelectorModalProps} from './types';
+
+function TextSelectorModal({value, description = '', onValueSelected, isVisible, onClose, ...rest}: TextSelectorModalProps) {
+ const {translate} = useLocalize();
+ const styles = useThemeStyles();
+
+ const [currentValue, setValue] = useState(value);
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+TextSelectorModal.displayName = 'TextSelectorModal';
+
+export default TextSelectorModal;
diff --git a/src/components/TextPicker/index.tsx b/src/components/TextPicker/index.tsx
new file mode 100644
index 000000000000..00c22e8b75ee
--- /dev/null
+++ b/src/components/TextPicker/index.tsx
@@ -0,0 +1,65 @@
+import React, {forwardRef, useState} from 'react';
+import type {ForwardedRef} from 'react';
+import {View} from 'react-native';
+import FormHelpMessage from '@components/FormHelpMessage';
+import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription';
+import useStyleUtils from '@hooks/useStyleUtils';
+import useThemeStyles from '@hooks/useThemeStyles';
+import variables from '@styles/variables';
+import TextSelectorModal from './TextSelectorModal';
+import type {TextPickerProps} from './types';
+
+function TextPicker({value, description, placeholder = '', errorText = '', onInputChange, furtherDetails, rightLabel, ...rest}: TextPickerProps, forwardedRef: ForwardedRef) {
+ const styles = useThemeStyles();
+ const StyleUtils = useStyleUtils();
+ const [isPickerVisible, setIsPickerVisible] = useState(false);
+
+ const showPickerModal = () => {
+ setIsPickerVisible(true);
+ };
+
+ const hidePickerModal = () => {
+ setIsPickerVisible(false);
+ };
+
+ const updateInput = (updatedValue: string) => {
+ if (updatedValue !== value) {
+ onInputChange?.(updatedValue);
+ }
+ hidePickerModal();
+ };
+
+ const descStyle = !value || value.length === 0 ? StyleUtils.getFontSizeStyle(variables.fontSizeLabel) : null;
+
+ return (
+
+
+
+
+
+
+
+ );
+}
+
+TextPicker.displayName = 'TextPicker';
+
+export default forwardRef(TextPicker);
diff --git a/src/components/TextPicker/types.ts b/src/components/TextPicker/types.ts
new file mode 100644
index 000000000000..179d16a07262
--- /dev/null
+++ b/src/components/TextPicker/types.ts
@@ -0,0 +1,46 @@
+import type {MenuItemBaseProps} from '@components/MenuItem';
+import type {BaseTextInputProps} from '@components/TextInput/BaseTextInput/types';
+import type {MaybePhraseKey} from '@libs/Localize';
+
+type TextProps = Exclude;
+
+type TextSelectorModalProps = {
+ /** Whether the modal is visible */
+ isVisible: boolean;
+
+ /** Current value */
+ value?: string;
+
+ /** Function to call when the user selects a item */
+ onValueSelected?: (value: string) => void;
+
+ /** Function to call when the user closes the modal */
+ onClose: () => void;
+
+ /** Whether to show the tooltip text */
+ shouldShowTooltips?: boolean;
+} & Pick &
+ TextProps;
+
+type TextPickerProps = {
+ /** Item to display */
+ value?: string;
+
+ /** A placeholder value to display */
+ placeholder?: string;
+
+ /** Form Error description */
+ errorText?: MaybePhraseKey;
+
+ /** Callback to call when the input changes */
+ onInputChange?: (value: string | undefined) => void;
+
+ /** Text to display under the main menu item */
+ furtherDetails?: string;
+
+ /** Whether to show the tooltip text */
+ shouldShowTooltips?: boolean;
+} & Pick &
+ TextProps;
+
+export type {TextSelectorModalProps, TextPickerProps};
diff --git a/src/components/TextWithTooltip/types.ts b/src/components/TextWithTooltip/types.ts
index 8169df911945..4705e2b69a68 100644
--- a/src/components/TextWithTooltip/types.ts
+++ b/src/components/TextWithTooltip/types.ts
@@ -4,7 +4,7 @@ type TextWithTooltipProps = {
/** The text to display */
text: string;
- /** Whether to show the toolip text */
+ /** Whether to show the tooltip text */
shouldShowTooltip: boolean;
/** Additional styles */
diff --git a/src/components/ValuePicker/ValueSelectorModal.tsx b/src/components/ValuePicker/ValueSelectorModal.tsx
index fad59d4e48e4..fda077898c78 100644
--- a/src/components/ValuePicker/ValueSelectorModal.tsx
+++ b/src/components/ValuePicker/ValueSelectorModal.tsx
@@ -29,7 +29,7 @@ function ValueSelectorModal({items = [], selectedItem, label = '', isVisible, on
style={styles.pb0}
includePaddingTop={false}
includeSafeAreaPaddingBottom={false}
- testID="ValueSelectorModal"
+ testID={ValueSelectorModal.displayName}
>
void;
- /** Whether to show the toolip text */
+ /** Whether to show the tooltip text */
shouldShowTooltips?: boolean;
};
@@ -56,7 +56,7 @@ type ValuePickerProps = {
/** Text to display under the main menu item */
furtherDetails?: string;
- /** Whether to show the toolip text */
+ /** Whether to show the tooltip text */
shouldShowTooltips?: boolean;
};
diff --git a/src/languages/en.ts b/src/languages/en.ts
index 4def1f7d7280..35b9348c999f 100755
--- a/src/languages/en.ts
+++ b/src/languages/en.ts
@@ -1784,18 +1784,23 @@ export default {
},
categories: {
deleteCategories: 'Delete categories',
+ deleteCategoriesPrompt: 'Are you sure you want to delete these categories?',
+ deleteCategory: 'Delete category',
+ deleteCategoryPrompt: 'Are you sure you want to delete this category?',
disableCategories: 'Disable categories',
+ disableCategory: 'Disable category',
enableCategories: 'Enable categories',
+ enableCategory: 'Enable category',
deleteFailureMessage: 'An error occurred while deleting the category, please try again.',
categoryName: 'Category name',
requiresCategory: 'Members must categorize all spend',
- enableCategory: 'Enable category',
subtitle: 'Get a better overview of where money is being spent. Use our default categories or add your own.',
emptyCategories: {
title: "You haven't created any categories",
subtitle: 'Add a category to organize your spend.',
},
- genericFailureMessage: 'An error occurred while updating the category, please try again.',
+ updateFailureMessage: 'An error occurred while updating the category, please try again.',
+ createFailureMessage: 'An error occurred while creating the category, please try again.',
addCategory: 'Add category',
editCategory: 'Edit category',
categoryRequiredError: 'Category name is required.',
@@ -1855,6 +1860,9 @@ export default {
title: "You haven't created any tags",
subtitle: 'Add a tag to track projects, locations, departments, and more.',
},
+ deleteTag: 'Delete tag',
+ deleteTagConfirmation: 'Are you sure that you want to delete this tag?',
+ deleteFailureMessage: 'An error occurred while deleting the tag, please try again.',
tagRequiredError: 'Tag name is required.',
existingTagError: 'A tag with this name already exists.',
genericFailureMessage: 'An error occurred while updating the tag, please try again.',
@@ -1864,6 +1872,13 @@ export default {
addRate: 'Add rate',
workspaceDefault: 'Workspace currency default',
foreignDefault: 'Foreign currency default',
+ customTaxName: 'Custom tax name',
+ value: 'Value',
+ errors: {
+ taxRateAlreadyExists: 'This tax name is already in use.',
+ valuePercentageRange: 'Please enter a valid percentage between 0 and 100.',
+ genericFailureMessage: 'An error occurred while updating the tax rate, please try again.',
+ },
},
emptyWorkspace: {
title: 'Create a workspace',
diff --git a/src/languages/es.ts b/src/languages/es.ts
index 96e7f945a32c..b095f1b23544 100644
--- a/src/languages/es.ts
+++ b/src/languages/es.ts
@@ -1808,18 +1808,23 @@ export default {
},
categories: {
deleteCategories: 'Eliminar categorías',
+ deleteCategoriesPrompt: '¿Estás seguro de que quieres eliminar estas categorías?',
+ deleteCategory: 'Eliminar categoría',
+ deleteCategoryPrompt: '¿Estás seguro de que quieres eliminar esta categoría?',
disableCategories: 'Desactivar categorías',
+ disableCategory: 'Desactivar categoría',
enableCategories: 'Activar categorías',
+ enableCategory: 'Activar categoría',
deleteFailureMessage: 'Se ha producido un error al intentar eliminar la categoría. Por favor, inténtalo más tarde.',
categoryName: 'Nombre de la categoría',
requiresCategory: 'Los miembros deben categorizar todos los gastos',
- enableCategory: 'Activar categoría',
subtitle: 'Obtén una visión general de dónde te gastas el dinero. Utiliza las categorías predeterminadas o añade las tuyas propias.',
emptyCategories: {
title: 'No has creado ninguna categoría',
subtitle: 'Añade una categoría para organizar tu gasto.',
},
- genericFailureMessage: 'Se ha producido un error al intentar eliminar la categoría. Por favor, inténtalo más tarde.',
+ updateFailureMessage: 'Se ha producido un error al intentar eliminar la categoría. Por favor, inténtalo más tarde.',
+ createFailureMessage: 'Se ha producido un error al intentar crear la categoría. Por favor, inténtalo más tarde.',
addCategory: 'Añadir categoría',
editCategory: 'Editar categoría',
categoryRequiredError: 'Lo nombre de la categoría es obligatorio.',
@@ -1879,6 +1884,9 @@ export default {
title: 'No has creado ninguna etiqueta',
subtitle: 'Añade una etiqueta para realizar el seguimiento de proyectos, ubicaciones, departamentos y otros.',
},
+ deleteTag: 'Eliminar etiqueta',
+ deleteTagConfirmation: '¿Estás seguro de que quieres eliminar esta etiqueta?',
+ deleteFailureMessage: 'Se ha producido un error al intentar eliminar la etiqueta. Por favor, inténtalo más tarde.',
tagRequiredError: 'Lo nombre de la etiqueta es obligatorio.',
existingTagError: 'Ya existe una etiqueta con este nombre.',
genericFailureMessage: 'Se produjo un error al actualizar la etiqueta, inténtelo nuevamente.',
@@ -1888,6 +1896,13 @@ export default {
addRate: 'Añadir tasa',
workspaceDefault: 'Moneda por defecto del espacio de trabajo',
foreignDefault: 'Moneda extranjera por defecto',
+ customTaxName: 'Nombre del impuesto',
+ value: 'Valor',
+ errors: {
+ taxRateAlreadyExists: 'Ya existe un impuesto con este nombre',
+ valuePercentageRange: 'Introduzca un porcentaje válido entre 0 y 100',
+ genericFailureMessage: 'Se produjo un error al actualizar el tipo impositivo, inténtelo nuevamente.',
+ },
},
emptyWorkspace: {
title: 'Crea un espacio de trabajo',
diff --git a/src/libs/API/parameters/CreatePolicyTaxParams.ts b/src/libs/API/parameters/CreatePolicyTaxParams.ts
new file mode 100644
index 000000000000..082758f0ae9e
--- /dev/null
+++ b/src/libs/API/parameters/CreatePolicyTaxParams.ts
@@ -0,0 +1,6 @@
+type CreatePolicyTaxParams = {
+ policyID: string;
+ taxFields: string;
+};
+
+export default CreatePolicyTaxParams;
diff --git a/src/libs/API/parameters/DeletePolicyTagsParams.ts b/src/libs/API/parameters/DeletePolicyTagsParams.ts
new file mode 100644
index 000000000000..0094ce318390
--- /dev/null
+++ b/src/libs/API/parameters/DeletePolicyTagsParams.ts
@@ -0,0 +1,10 @@
+type DeletePolicyTagsParams = {
+ policyID: string;
+ /**
+ * Stringified JSON object with type of following structure:
+ * Array
+ */
+ tags: string;
+};
+
+export default DeletePolicyTagsParams;
diff --git a/src/libs/API/parameters/SetPolicyCurrencyDefaultParams.ts b/src/libs/API/parameters/SetPolicyCurrencyDefaultParams.ts
new file mode 100644
index 000000000000..bfbfdf9e3b35
--- /dev/null
+++ b/src/libs/API/parameters/SetPolicyCurrencyDefaultParams.ts
@@ -0,0 +1,6 @@
+type SetPolicyCurrencyDefaultParams = {
+ policyID: string;
+ taxCode: string;
+};
+
+export default SetPolicyCurrencyDefaultParams;
diff --git a/src/libs/API/parameters/SetPolicyCustomTaxNameParams.ts b/src/libs/API/parameters/SetPolicyCustomTaxNameParams.ts
new file mode 100644
index 000000000000..49dbdb7e215e
--- /dev/null
+++ b/src/libs/API/parameters/SetPolicyCustomTaxNameParams.ts
@@ -0,0 +1,6 @@
+type SetPolicyCustomTaxNameParams = {
+ policyID: string;
+ customTaxName: string;
+};
+
+export default SetPolicyCustomTaxNameParams;
diff --git a/src/libs/API/parameters/SetPolicyForeignCurrencyDefaultParams.ts b/src/libs/API/parameters/SetPolicyForeignCurrencyDefaultParams.ts
new file mode 100644
index 000000000000..5dff8aabe427
--- /dev/null
+++ b/src/libs/API/parameters/SetPolicyForeignCurrencyDefaultParams.ts
@@ -0,0 +1,6 @@
+type SetPolicyForeignCurrencyDefaultParams = {
+ policyID: string;
+ taxCode: string;
+};
+
+export default SetPolicyForeignCurrencyDefaultParams;
diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts
index 5213bc4cd984..efb2f1fff7da 100644
--- a/src/libs/API/parameters/index.ts
+++ b/src/libs/API/parameters/index.ts
@@ -174,6 +174,7 @@ export type {default as EnablePolicyReportFieldsParams} from './EnablePolicyRepo
export type {default as AcceptJoinRequestParams} from './AcceptJoinRequest';
export type {default as DeclineJoinRequestParams} from './DeclineJoinRequest';
export type {default as JoinPolicyInviteLinkParams} from './JoinPolicyInviteLink';
+export type {default as CreatePolicyTaxParams} from './CreatePolicyTaxParams';
export type {default as OpenPolicyWorkflowsPageParams} from './OpenPolicyWorkflowsPageParams';
export type {default as OpenPolicyDistanceRatesPageParams} from './OpenPolicyDistanceRatesPageParams';
export type {default as OpenPolicyTaxesPageParams} from './OpenPolicyTaxesPageParams';
@@ -181,3 +182,7 @@ export type {default as EnablePolicyTaxesParams} from './EnablePolicyTaxesParams
export type {default as OpenPolicyMoreFeaturesPageParams} from './OpenPolicyMoreFeaturesPageParams';
export type {default as CreatePolicyDistanceRateParams} from './CreatePolicyDistanceRateParams';
export type {default as CreatePolicyTagsParams} from './CreatePolicyTagsParams';
+export type {default as DeletePolicyTagsParams} from './DeletePolicyTagsParams';
+export type {default as SetPolicyCustomTaxNameParams} from './SetPolicyCustomTaxNameParams';
+export type {default as SetPolicyForeignCurrencyDefaultParams} from './SetPolicyForeignCurrencyDefaultParams';
+export type {default as SetPolicyCurrencyDefaultParams} from './SetPolicyCurrencyDefaultParams';
diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts
index 14d963b5fa44..132fbc60b967 100644
--- a/src/libs/API/types.ts
+++ b/src/libs/API/types.ts
@@ -126,6 +126,7 @@ const WRITE_COMMANDS = {
DELETE_WORKSPACE_CATEGORIES: 'DeleteWorkspaceCategories',
SET_POLICY_REQUIRES_TAG: 'SetPolicyRequiresTag',
RENAME_POLICY_TAG_LIST: 'RenamePolicyTaglist',
+ DELETE_POLICY_TAGS: 'Policy_IndependentTaglist_Tags_Remove',
CREATE_TASK: 'CreateTask',
CANCEL_TASK: 'CancelTask',
EDIT_TASK_ASSIGNEE: 'EditTaskAssignee',
@@ -173,9 +174,13 @@ const WRITE_COMMANDS = {
ENABLE_POLICY_TAXES: 'EnablePolicyTaxes',
ENABLE_POLICY_WORKFLOWS: 'EnablePolicyWorkflows',
ENABLE_POLICY_REPORT_FIELDS: 'EnablePolicyReportFields',
+ SET_POLICY_TAXES_CURRENCY_DEFAULT: 'SetPolicyCurrencyDefaultTax',
+ SET_POLICY_TAXES_FOREIGN_CURRENCY_DEFAULT: 'SetPolicyForeignCurrencyDefaultTax',
+ SET_POLICY_CUSTOM_TAX_NAME: 'SetPolicyCustomTaxName',
JOIN_POLICY_VIA_INVITE_LINK: 'JoinWorkspaceViaInviteLink',
ACCEPT_JOIN_REQUEST: 'AcceptJoinRequest',
DECLINE_JOIN_REQUEST: 'DeclineJoinRequest',
+ CREATE_POLICY_TAX: 'CreatePolicyTax',
CREATE_POLICY_DISTANCE_RATE: 'CreatePolicyDistanceRate',
} as const;
@@ -294,6 +299,7 @@ type WriteCommandParameters = {
[WRITE_COMMANDS.RENAME_POLICY_TAG_LIST]: Parameters.RenamePolicyTaglist;
[WRITE_COMMANDS.CREATE_POLICY_TAG]: Parameters.CreatePolicyTagsParams;
[WRITE_COMMANDS.SET_POLICY_TAGS_ENABLED]: Parameters.SetPolicyTagsEnabled;
+ [WRITE_COMMANDS.DELETE_POLICY_TAGS]: Parameters.DeletePolicyTagsParams;
[WRITE_COMMANDS.CREATE_TASK]: Parameters.CreateTaskParams;
[WRITE_COMMANDS.CANCEL_TASK]: Parameters.CancelTaskParams;
[WRITE_COMMANDS.EDIT_TASK_ASSIGNEE]: Parameters.EditTaskAssigneeParams;
@@ -351,6 +357,10 @@ type WriteCommandParameters = {
[WRITE_COMMANDS.JOIN_POLICY_VIA_INVITE_LINK]: Parameters.JoinPolicyInviteLinkParams;
[WRITE_COMMANDS.ACCEPT_JOIN_REQUEST]: Parameters.AcceptJoinRequestParams;
[WRITE_COMMANDS.DECLINE_JOIN_REQUEST]: Parameters.DeclineJoinRequestParams;
+ [WRITE_COMMANDS.SET_POLICY_TAXES_CURRENCY_DEFAULT]: Parameters.SetPolicyCurrencyDefaultParams;
+ [WRITE_COMMANDS.SET_POLICY_CUSTOM_TAX_NAME]: Parameters.SetPolicyCustomTaxNameParams;
+ [WRITE_COMMANDS.SET_POLICY_TAXES_FOREIGN_CURRENCY_DEFAULT]: Parameters.SetPolicyForeignCurrencyDefaultParams;
+ [WRITE_COMMANDS.CREATE_POLICY_TAX]: Parameters.CreatePolicyTaxParams;
[WRITE_COMMANDS.CREATE_POLICY_DISTANCE_RATE]: Parameters.CreatePolicyDistanceRateParams;
};
diff --git a/src/libs/DateUtils.ts b/src/libs/DateUtils.ts
index 6da5c8af1ff2..4d4f8d425681 100644
--- a/src/libs/DateUtils.ts
+++ b/src/libs/DateUtils.ts
@@ -27,7 +27,8 @@ import {
subMinutes,
} from 'date-fns';
import {formatInTimeZone, format as tzFormat, utcToZonedTime, zonedTimeToUtc} from 'date-fns-tz';
-import {enGB, es} from 'date-fns/locale';
+import enGB from 'date-fns/locale/en-GB';
+import es from 'date-fns/locale/es';
import throttle from 'lodash/throttle';
import Onyx from 'react-native-onyx';
import type {ValueOf} from 'type-fest';
diff --git a/src/libs/IntlPolyfill/index.native.ts b/src/libs/IntlPolyfill/index.native.ts
index 7a21ae26bfa4..ca1c8f4c250e 100644
--- a/src/libs/IntlPolyfill/index.native.ts
+++ b/src/libs/IntlPolyfill/index.native.ts
@@ -1,16 +1,18 @@
+import polyfillDateTimeFormat from './polyfillDateTimeFormat';
import polyfillListFormat from './polyfillListFormat';
+import polyfillNumberFormat from './polyfillNumberFormat';
import type IntlPolyfill from './types';
/**
* Polyfill the Intl API, always performed for native devices.
*/
const intlPolyfill: IntlPolyfill = () => {
- // Native devices require extra polyfills which are
- // not yet implemented in hermes.
- // see support: https://hermesengine.dev/docs/intl/
-
+ // Native devices require extra polyfills
+ require('@formatjs/intl-getcanonicallocales/polyfill');
require('@formatjs/intl-locale/polyfill');
-
+ require('@formatjs/intl-pluralrules/polyfill');
+ polyfillNumberFormat();
+ polyfillDateTimeFormat();
polyfillListFormat();
};
diff --git a/src/libs/LocalePhoneNumber.ts b/src/libs/LocalePhoneNumber.ts
index 933aa7937560..460d5fc0fe9f 100644
--- a/src/libs/LocalePhoneNumber.ts
+++ b/src/libs/LocalePhoneNumber.ts
@@ -1,5 +1,6 @@
import Str from 'expensify-common/lib/str';
import Onyx from 'react-native-onyx';
+import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import {parsePhoneNumber} from './PhoneNumber';
@@ -18,6 +19,10 @@ function formatPhoneNumber(number: string): string {
return '';
}
+ // do not parse the string, if it doesn't contain the SMS domain and it's not a phone number
+ if (number.indexOf(CONST.SMS.DOMAIN) === -1 && !CONST.REGEX.DIGITS_AND_PLUS.test(number)) {
+ return number;
+ }
const numberWithoutSMSDomain = Str.removeSMSDomain(number);
const parsedPhoneNumber = parsePhoneNumber(numberWithoutSMSDomain);
diff --git a/src/libs/Localize/index.ts b/src/libs/Localize/index.ts
index 64d07897aa8a..0e65e5b8be87 100644
--- a/src/libs/Localize/index.ts
+++ b/src/libs/Localize/index.ts
@@ -1,6 +1,7 @@
import PropTypes from 'prop-types';
import * as RNLocalize from 'react-native-localize';
import Onyx from 'react-native-onyx';
+import type {ValueOf} from 'type-fest';
import Log from '@libs/Log';
import type {MessageElementBase, MessageTextElement} from '@libs/MessageElement';
import Config from '@src/CONFIG';
@@ -50,34 +51,109 @@ type PhraseParameters = T extends (...args: infer A) => string ? A : never[];
type Phrase = TranslationFlatObject[TKey] extends (...args: infer A) => unknown ? (...args: A) => string : string;
/**
- * Return translated string for given locale and phrase
+ * Map to store translated values for each locale.
+ * This is used to avoid translating the same phrase multiple times.
*
- * @param [desiredLanguage] eg 'en', 'es-ES'
- * @param [phraseParameters] Parameters to supply if the phrase is a template literal.
+ * The data is stored in the following format:
+ *
+ * {
+ * "en": {
+ * "name": "Name",
+ * }
+ *
+ * Note: We are not storing any translated values for phrases with variables,
+ * as they have higher chance of being unique, so we'll end up wasting space
+ * in our cache.
*/
-function translate(desiredLanguage: 'en' | 'es' | 'es-ES' | 'es_ES', phraseKey: TKey, ...phraseParameters: PhraseParameters>): string {
- // Search phrase in full locale e.g. es-ES
- const language = desiredLanguage === CONST.LOCALES.ES_ES_ONFIDO ? CONST.LOCALES.ES_ES : desiredLanguage;
- let translatedPhrase = translations?.[language]?.[phraseKey] as Phrase;
+const translationCache = new Map, Map>(
+ Object.values(CONST.LOCALES).reduce((cache, locale) => {
+ cache.push([locale, new Map()]);
+ return cache;
+ }, [] as Array<[ValueOf, Map]>),
+);
+
+/**
+ * Helper function to get the translated string for given
+ * locale and phrase. This function is used to avoid
+ * duplicate code in getTranslatedPhrase and translate functions.
+ *
+ * This function first checks if the phrase is already translated
+ * and in the cache, it returns the translated value from the cache.
+ *
+ * If the phrase is not translated, it checks if the phrase is
+ * available in the given locale. If it is, it translates the
+ * phrase and stores the translated value in the cache and returns
+ * the translated value.
+ *
+ * @param language
+ * @param phraseKey
+ * @param fallbackLanguage
+ * @param phraseParameters
+ */
+function getTranslatedPhrase(
+ language: 'en' | 'es' | 'es-ES',
+ phraseKey: TKey,
+ fallbackLanguage: 'en' | 'es' | null = null,
+ ...phraseParameters: PhraseParameters>
+): string | null {
+ // Get the cache for the above locale
+ const cacheForLocale = translationCache.get(language);
+
+ // Directly access and assign the translated value from the cache, instead of
+ // going through map.has() and map.get() to avoid multiple lookups.
+ const valueFromCache = cacheForLocale?.get(phraseKey);
+
+ // If the phrase is already translated, return the translated value
+ if (valueFromCache) {
+ return valueFromCache;
+ }
+
+ const translatedPhrase = translations?.[language]?.[phraseKey] as Phrase;
+
if (translatedPhrase) {
- return typeof translatedPhrase === 'function' ? translatedPhrase(...phraseParameters) : translatedPhrase;
+ if (typeof translatedPhrase === 'function') {
+ return translatedPhrase(...phraseParameters);
+ }
+
+ // We set the translated value in the cache only for the phrases without parameters.
+ cacheForLocale?.set(phraseKey, translatedPhrase);
+ return translatedPhrase;
+ }
+
+ if (!fallbackLanguage) {
+ return null;
}
// Phrase is not found in full locale, search it in fallback language e.g. es
- const languageAbbreviation = desiredLanguage.substring(0, 2) as 'en' | 'es';
- translatedPhrase = translations?.[languageAbbreviation]?.[phraseKey] as Phrase;
- if (translatedPhrase) {
- return typeof translatedPhrase === 'function' ? translatedPhrase(...phraseParameters) : translatedPhrase;
+ const fallbacktranslatedPhrase = getTranslatedPhrase(fallbackLanguage, phraseKey, null, ...phraseParameters);
+
+ if (fallbacktranslatedPhrase) {
+ return fallbacktranslatedPhrase;
}
- if (languageAbbreviation !== CONST.LOCALES.DEFAULT) {
- Log.alert(`${phraseKey} was not found in the ${languageAbbreviation} locale`);
+ if (fallbackLanguage !== CONST.LOCALES.DEFAULT) {
+ Log.alert(`${phraseKey} was not found in the ${fallbackLanguage} locale`);
}
// Phrase is not translated, search it in default language (en)
- translatedPhrase = translations?.[CONST.LOCALES.DEFAULT]?.[phraseKey] as Phrase;
+ return getTranslatedPhrase(CONST.LOCALES.DEFAULT, phraseKey, null, ...phraseParameters);
+}
+
+/**
+ * Return translated string for given locale and phrase
+ *
+ * @param [desiredLanguage] eg 'en', 'es-ES'
+ * @param [phraseParameters] Parameters to supply if the phrase is a template literal.
+ */
+function translate(desiredLanguage: 'en' | 'es' | 'es-ES' | 'es_ES', phraseKey: TKey, ...phraseParameters: PhraseParameters>): string {
+ // Search phrase in full locale e.g. es-ES
+ const language = desiredLanguage === CONST.LOCALES.ES_ES_ONFIDO ? CONST.LOCALES.ES_ES : desiredLanguage;
+ // Phrase is not found in full locale, search it in fallback language e.g. es
+ const languageAbbreviation = desiredLanguage.substring(0, 2) as 'en' | 'es';
+
+ const translatedPhrase = getTranslatedPhrase(language, phraseKey, languageAbbreviation, ...phraseParameters);
if (translatedPhrase) {
- return typeof translatedPhrase === 'function' ? translatedPhrase(...phraseParameters) : translatedPhrase;
+ return translatedPhrase;
}
// Phrase is not found in default language, on production and staging log an alert to server
diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx
index 81a90e94b3c9..69741141f4f3 100644
--- a/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx
+++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx
@@ -268,6 +268,10 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../pages/workspace/tags/TagSettingsPage').default as React.ComponentType,
[SCREENS.WORKSPACE.TAGS_EDIT]: () => require('../../../pages/workspace/tags/WorkspaceEditTagsPage').default as React.ComponentType,
[SCREENS.WORKSPACE.TAG_CREATE]: () => require('../../../pages/workspace/tags/WorkspaceCreateTagPage').default as React.ComponentType,
+ [SCREENS.WORKSPACE.TAXES_SETTINGS]: () => require('../../../pages/workspace/taxes/WorkspaceTaxesSettingsPage').default as React.ComponentType,
+ [SCREENS.WORKSPACE.TAXES_SETTINGS_CUSTOM_TAX_NAME]: () => require('../../../pages/workspace/taxes/WorkspaceTaxesSettingsCustomTaxName').default as React.ComponentType,
+ [SCREENS.WORKSPACE.TAXES_SETTINGS_FOREIGN_CURRENCY_DEFAULT]: () => require('../../../pages/workspace/taxes/WorkspaceTaxesSettingsForeignCurrency').default as React.ComponentType,
+ [SCREENS.WORKSPACE.TAXES_SETTINGS_WORKSPACE_CURRENCY_DEFAULT]: () => require('../../../pages/workspace/taxes/WorkspaceTaxesSettingsWorkspaceCurrency').default as React.ComponentType,
[SCREENS.REIMBURSEMENT_ACCOUNT]: () => require('../../../pages/ReimbursementAccount/ReimbursementAccountPage').default as React.ComponentType,
[SCREENS.GET_ASSISTANCE]: () => require('../../../pages/GetAssistancePage').default as React.ComponentType,
[SCREENS.SETTINGS.TWO_FACTOR_AUTH]: () => require('../../../pages/settings/Security/TwoFactorAuth/TwoFactorAuthPage').default as React.ComponentType,
@@ -278,6 +282,7 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../pages/settings/ExitSurvey/ExitSurveyConfirmPage').default as React.ComponentType,
[SCREENS.WORKSPACE.WORKFLOWS_AUTO_REPORTING_FREQUENCY]: () => require('../../../pages/workspace/workflows/WorkspaceAutoReportingFrequencyPage').default as React.ComponentType,
[SCREENS.WORKSPACE.WORKFLOWS_AUTO_REPORTING_MONTHLY_OFFSET]: () => require('../../../pages/workspace/workflows/WorkspaceAutoReportingMonthlyOffsetPage').default as React.ComponentType,
+ [SCREENS.WORKSPACE.TAX_CREATE]: () => require('../../../pages/workspace/taxes/WorkspaceCreateTaxPage').default as React.ComponentType,
});
const EnablePaymentsStackNavigator = createModalStackNavigator({
diff --git a/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/BottomTabBar.tsx b/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/BottomTabBar.tsx
index 3a59e42bcca1..6d20361b75f5 100644
--- a/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/BottomTabBar.tsx
+++ b/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/BottomTabBar.tsx
@@ -61,16 +61,16 @@ function BottomTabBar({isLoadingApp = false}: PurposeForUsingExpensifyModalProps
return (
- {
- Navigation.navigate(ROUTES.HOME);
- }}
- role={CONST.ROLE.BUTTON}
- accessibilityLabel={translate('common.chats')}
- wrapperStyle={styles.flex1}
- style={styles.bottomTabBarItem}
- >
-
+
+ {
+ Navigation.navigate(ROUTES.HOME);
+ }}
+ role={CONST.ROLE.BUTTON}
+ accessibilityLabel={translate('common.chats')}
+ wrapperStyle={styles.flex1}
+ style={styles.bottomTabBarItem}
+ >
)}
-
-
+
+
-
-
-
+
);
}
diff --git a/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts b/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts
index 8713cbbcf6ba..6f06df9bf2ff 100755
--- a/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts
+++ b/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts
@@ -11,7 +11,13 @@ const FULL_SCREEN_TO_RHP_MAPPING: Partial> = {
SCREENS.WORKSPACE.WORKFLOWS_AUTO_REPORTING_MONTHLY_OFFSET,
SCREENS.WORKSPACE.WORKFLOWS_PAYER,
],
- [SCREENS.WORKSPACE.TAGS]: [SCREENS.WORKSPACE.TAGS_SETTINGS, SCREENS.WORKSPACE.TAGS_EDIT, SCREENS.WORKSPACE.TAG_CREATE, SCREENS.WORKSPACE.TAG_SETTINGS],
+ [SCREENS.WORKSPACE.TAXES]: [
+ SCREENS.WORKSPACE.TAXES_SETTINGS,
+ SCREENS.WORKSPACE.TAXES_SETTINGS_CUSTOM_TAX_NAME,
+ SCREENS.WORKSPACE.TAXES_SETTINGS_FOREIGN_CURRENCY_DEFAULT,
+ SCREENS.WORKSPACE.TAXES_SETTINGS_WORKSPACE_CURRENCY_DEFAULT,
+ ],
+ [SCREENS.WORKSPACE.TAGS]: [SCREENS.WORKSPACE.TAGS_SETTINGS, SCREENS.WORKSPACE.TAGS_EDIT, SCREENS.WORKSPACE.TAG_CREATE, SCREENS.WORKSPACE.TAG_SETTINGS, SCREENS.WORKSPACE.TAX_CREATE],
[SCREENS.WORKSPACE.CATEGORIES]: [SCREENS.WORKSPACE.CATEGORY_CREATE, SCREENS.WORKSPACE.CATEGORY_SETTINGS, SCREENS.WORKSPACE.CATEGORIES_SETTINGS, SCREENS.WORKSPACE.CATEGORY_EDIT],
[SCREENS.WORKSPACE.DISTANCE_RATES]: [SCREENS.WORKSPACE.CREATE_DISTANCE_RATE],
};
diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts
index f859ed9027a1..ae8d47a69988 100644
--- a/src/libs/Navigation/linkingConfig/config.ts
+++ b/src/libs/Navigation/linkingConfig/config.ts
@@ -307,6 +307,18 @@ const config: LinkingOptions['config'] = {
tagName: (tagName: string) => decodeURIComponent(tagName),
},
},
+ [SCREENS.WORKSPACE.TAXES_SETTINGS]: {
+ path: ROUTES.WORKSPACE_TAXES_SETTINGS.route,
+ },
+ [SCREENS.WORKSPACE.TAXES_SETTINGS_CUSTOM_TAX_NAME]: {
+ path: ROUTES.WORKSPACE_TAXES_SETTINGS_CUSTOM_TAX_NAME.route,
+ },
+ [SCREENS.WORKSPACE.TAXES_SETTINGS_FOREIGN_CURRENCY_DEFAULT]: {
+ path: ROUTES.WORKSPACE_TAXES_SETTINGS_FOREIGN_CURRENCY_DEFAULT.route,
+ },
+ [SCREENS.WORKSPACE.TAXES_SETTINGS_WORKSPACE_CURRENCY_DEFAULT]: {
+ path: ROUTES.WORKSPACE_TAXES_SETTINGS_WORKSPACE_CURRENCY_DEFAULT.route,
+ },
[SCREENS.REIMBURSEMENT_ACCOUNT]: {
path: ROUTES.BANK_ACCOUNT_WITH_STEP_TO_OPEN.route,
exact: true,
@@ -330,6 +342,9 @@ const config: LinkingOptions['config'] = {
[SCREENS.SETTINGS.EXIT_SURVEY.CONFIRM]: {
path: ROUTES.SETTINGS_EXIT_SURVEY_CONFIRM.route,
},
+ [SCREENS.WORKSPACE.TAX_CREATE]: {
+ path: ROUTES.WORKSPACE_TAX_CREATE.route,
+ },
},
},
[SCREENS.RIGHT_MODAL.PRIVATE_NOTES]: {
diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts
index 6c59a2558f21..4c9bdb579605 100644
--- a/src/libs/Navigation/types.ts
+++ b/src/libs/Navigation/types.ts
@@ -193,6 +193,18 @@ type SettingsNavigatorParamList = {
policyID: string;
tagName: string;
};
+ [SCREENS.WORKSPACE.TAXES_SETTINGS]: {
+ policyID: string;
+ };
+ [SCREENS.WORKSPACE.TAXES_SETTINGS_CUSTOM_TAX_NAME]: {
+ policyID: string;
+ };
+ [SCREENS.WORKSPACE.TAXES_SETTINGS_FOREIGN_CURRENCY_DEFAULT]: {
+ policyID: string;
+ };
+ [SCREENS.WORKSPACE.TAXES_SETTINGS_WORKSPACE_CURRENCY_DEFAULT]: {
+ policyID: string;
+ };
[SCREENS.WORKSPACE.MEMBER_DETAILS]: {
policyID: string;
accountID: string;
@@ -220,6 +232,9 @@ type SettingsNavigatorParamList = {
[SCREENS.SETTINGS.EXIT_SURVEY.CONFIRM]: {
backTo: Routes;
};
+ [SCREENS.WORKSPACE.TAX_CREATE]: {
+ policyID: string;
+ };
} & ReimbursementAccountNavigatorParamList;
type NewChatNavigatorParamList = {
diff --git a/src/libs/OptionsListUtils.ts b/src/libs/OptionsListUtils.ts
index e3e989dd877b..ff55343fa762 100644
--- a/src/libs/OptionsListUtils.ts
+++ b/src/libs/OptionsListUtils.ts
@@ -1202,8 +1202,8 @@ function hasEnabledTags(policyTagList: Array
* @param taxRates - The original tax rates object.
* @returns The transformed tax rates object.g
*/
-function transformedTaxRates(taxRates: TaxRatesWithDefault | undefined): Record {
- const defaultTaxKey = taxRates?.defaultExternalID;
+function transformedTaxRates(taxRates: TaxRatesWithDefault | undefined, defaultKey?: string): Record {
+ const defaultTaxKey = defaultKey ?? taxRates?.defaultExternalID;
const getModifiedName = (data: TaxRate, code: string) => `${data.name} (${data.value})${defaultTaxKey === code ? ` • ${Localize.translateLocal('common.default')}` : ''}`;
const taxes = Object.fromEntries(Object.entries(taxRates?.taxes ?? {}).map(([code, data]) => [code, {...data, code, modifiedName: getModifiedName(data, code), name: data.name}]));
return taxes;
@@ -1234,10 +1234,10 @@ function getTaxRatesOptions(taxRates: Array>): Option[] {
/**
* Builds the section list for tax rates
*/
-function getTaxRatesSection(taxRates: TaxRatesWithDefault | undefined, selectedOptions: Category[], searchInputValue: string): CategorySection[] {
+function getTaxRatesSection(taxRates: TaxRatesWithDefault | undefined, selectedOptions: Category[], searchInputValue: string, defaultTaxKey?: string): CategorySection[] {
const policyRatesSections = [];
- const taxes = transformedTaxRates(taxRates);
+ const taxes = transformedTaxRates(taxRates, defaultTaxKey);
const sortedTaxRates = sortTaxRates(taxes);
const enabledTaxRates = sortedTaxRates.filter((taxRate) => !taxRate.isDisabled);
@@ -1264,7 +1264,7 @@ function getTaxRatesSection(taxRates: TaxRatesWithDefault | undefined, selectedO
}
if (searchInputValue) {
- const searchTaxRates = enabledTaxRates.filter((taxRate) => taxRate.modifiedName.toLowerCase().includes(searchInputValue.toLowerCase()));
+ const searchTaxRates = enabledTaxRates.filter((taxRate) => taxRate.modifiedName?.toLowerCase().includes(searchInputValue.toLowerCase()));
policyRatesSections.push({
// "Search" section
@@ -1290,7 +1290,7 @@ function getTaxRatesSection(taxRates: TaxRatesWithDefault | undefined, selectedO
}
const selectedOptionNames = selectedOptions.map((selectedOption) => selectedOption.name);
- const filteredTaxRates = enabledTaxRates.filter((taxRate) => !selectedOptionNames.includes(taxRate.modifiedName));
+ const filteredTaxRates = enabledTaxRates.filter((taxRate) => taxRate.modifiedName && !selectedOptionNames.includes(taxRate.modifiedName));
if (selectedOptions.length > 0) {
const selectedTaxRatesOptions = selectedOptions.map((option) => {
@@ -2074,6 +2074,7 @@ export {
formatSectionsFromSearchTerm,
transformedTaxRates,
getShareLogOptions,
+ getTaxRatesSection,
};
-export type {MemberForList, CategorySection, GetOptions, PayeePersonalDetails};
+export type {MemberForList, CategorySection, GetOptions, PayeePersonalDetails, Category};
diff --git a/src/libs/PersonalDetailsUtils.ts b/src/libs/PersonalDetailsUtils.ts
index 65aadd440010..c8107c22bb1a 100644
--- a/src/libs/PersonalDetailsUtils.ts
+++ b/src/libs/PersonalDetailsUtils.ts
@@ -25,7 +25,13 @@ Onyx.connect({
});
function getDisplayNameOrDefault(passedPersonalDetails?: Partial | null, defaultValue = '', shouldFallbackToHidden = true, shouldAddCurrentUserPostfix = false): string {
- let displayName = passedPersonalDetails?.displayName ? passedPersonalDetails.displayName.replace(CONST.REGEX.MERGED_ACCOUNT_PREFIX, '') : '';
+ let displayName = passedPersonalDetails?.displayName ?? '';
+
+ // If the displayName starts with the merged account prefix, remove it.
+ if (displayName.startsWith(CONST.MERGED_ACCOUNT_PREFIX)) {
+ // Remove the merged account prefix from the displayName.
+ displayName = displayName.substring(CONST.MERGED_ACCOUNT_PREFIX.length);
+ }
// If the displayName is not set by the user, the backend sets the diplayName same as the login so
// we need to remove the sms domain from the displayName if it is an sms login.
@@ -37,9 +43,10 @@ function getDisplayNameOrDefault(passedPersonalDetails?: Partial): boolean
return Object.values(policyMembers ?? {}).some((member) => Object.keys(member?.errors ?? {}).length > 0);
}
+/**
+ * Check if the policy has any tax rate errors.
+ */
+function hasTaxRateError(policy: OnyxEntry): boolean {
+ return Object.values(policy?.taxRates?.taxes ?? {}).some((taxRate) => Object.keys(taxRate?.errors ?? {}).length > 0);
+}
+
/**
* Check if the policy has any errors within the categories.
*/
@@ -299,6 +306,7 @@ export {
getPathWithoutPolicyID,
getPolicyMembersByIdWithoutCurrentUser,
goBackFromInvalidPolicy,
+ hasTaxRateError,
hasPolicyCategoriesError,
};
diff --git a/src/libs/Pusher/pusher.ts b/src/libs/Pusher/pusher.ts
index 3cb15c0f3fc3..48c5e5c1409f 100644
--- a/src/libs/Pusher/pusher.ts
+++ b/src/libs/Pusher/pusher.ts
@@ -1,5 +1,6 @@
import isObject from 'lodash/isObject';
import type {Channel, ChannelAuthorizerGenerator, Options} from 'pusher-js/with-encryption';
+import {InteractionManager} from 'react-native';
import Onyx from 'react-native-onyx';
import type {LiteralUnion, ValueOf} from 'type-fest';
import Log from '@libs/Log';
@@ -226,48 +227,50 @@ function subscribe(
onResubscribe = () => {},
): Promise {
return new Promise((resolve, reject) => {
- // We cannot call subscribe() before init(). Prevent any attempt to do this on dev.
- if (!socket) {
- throw new Error(`[Pusher] instance not found. Pusher.subscribe()
+ InteractionManager.runAfterInteractions(() => {
+ // We cannot call subscribe() before init(). Prevent any attempt to do this on dev.
+ if (!socket) {
+ throw new Error(`[Pusher] instance not found. Pusher.subscribe()
most likely has been called before Pusher.init()`);
- }
+ }
- Log.info('[Pusher] Attempting to subscribe to channel', false, {channelName, eventName});
- let channel = getChannel(channelName);
-
- if (!channel || !channel.subscribed) {
- channel = socket.subscribe(channelName);
- let isBound = false;
- channel.bind('pusher:subscription_succeeded', () => {
- // Check so that we do not bind another event with each reconnect attempt
- if (!isBound) {
- bindEventToChannel(channel, eventName, eventCallback);
- resolve();
- isBound = true;
- return;
- }
-
- // When subscribing for the first time we register a success callback that can be
- // called multiple times when the subscription succeeds again in the future
- // e.g. as a result of Pusher disconnecting and reconnecting. This callback does
- // not fire on the first subscription_succeeded event.
- onResubscribe();
- });
-
- channel.bind('pusher:subscription_error', (data: PusherSubscribtionErrorData = {}) => {
- const {type, error, status} = data;
- Log.hmmm('[Pusher] Issue authenticating with Pusher during subscribe attempt.', {
- channelName,
- status,
- type,
- error,
+ Log.info('[Pusher] Attempting to subscribe to channel', false, {channelName, eventName});
+ let channel = getChannel(channelName);
+
+ if (!channel || !channel.subscribed) {
+ channel = socket.subscribe(channelName);
+ let isBound = false;
+ channel.bind('pusher:subscription_succeeded', () => {
+ // Check so that we do not bind another event with each reconnect attempt
+ if (!isBound) {
+ bindEventToChannel(channel, eventName, eventCallback);
+ resolve();
+ isBound = true;
+ return;
+ }
+
+ // When subscribing for the first time we register a success callback that can be
+ // called multiple times when the subscription succeeds again in the future
+ // e.g. as a result of Pusher disconnecting and reconnecting. This callback does
+ // not fire on the first subscription_succeeded event.
+ onResubscribe();
});
- reject(error);
- });
- } else {
- bindEventToChannel(channel, eventName, eventCallback);
- resolve();
- }
+
+ channel.bind('pusher:subscription_error', (data: PusherSubscribtionErrorData = {}) => {
+ const {type, error, status} = data;
+ Log.hmmm('[Pusher] Issue authenticating with Pusher during subscribe attempt.', {
+ channelName,
+ status,
+ type,
+ error,
+ });
+ reject(error);
+ });
+ } else {
+ bindEventToChannel(channel, eventName, eventCallback);
+ resolve();
+ }
+ });
});
}
diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts
index 3e4950bc893b..e39180db7fd0 100644
--- a/src/libs/ReportUtils.ts
+++ b/src/libs/ReportUtils.ts
@@ -794,7 +794,7 @@ function isAnnounceRoom(report: OnyxEntry): boolean {
* Whether the provided report is a default room
*/
function isDefaultRoom(report: OnyxEntry): boolean {
- return [CONST.REPORT.CHAT_TYPE.POLICY_ADMINS, CONST.REPORT.CHAT_TYPE.POLICY_ANNOUNCE, CONST.REPORT.CHAT_TYPE.DOMAIN_ALL].some((type) => type === getChatType(report));
+ return CONST.DEFAULT_POLICY_ROOM_CHAT_TYPES.some((type) => type === getChatType(report));
}
/**
diff --git a/src/libs/ValidationUtils.ts b/src/libs/ValidationUtils.ts
index 3d5f23a84f74..5876ccf5d7d7 100644
--- a/src/libs/ValidationUtils.ts
+++ b/src/libs/ValidationUtils.ts
@@ -8,7 +8,7 @@ import type {OnyxCollection} from 'react-native-onyx';
import type {FormInputErrors, FormOnyxKeys, FormOnyxValues, FormValue} from '@components/Form/types';
import CONST from '@src/CONST';
import type {OnyxFormKey} from '@src/ONYXKEYS';
-import type {Report} from '@src/types/onyx';
+import type {Report, TaxRates} from '@src/types/onyx';
import * as CardUtils from './CardUtils';
import DateUtils from './DateUtils';
import type {MaybePhraseKey} from './Localize';
@@ -460,6 +460,21 @@ function prepareValues(values: ValuesType): ValuesType {
return trimmedStringValues;
}
+/**
+ * Validates the given value if it is correct percentage value.
+ */
+function isValidPercentage(value: string): boolean {
+ const parsedValue = Number(value);
+ return !Number.isNaN(parsedValue) && parsedValue >= 0 && parsedValue <= 100;
+}
+
+/**
+ * Validates the given value if it is correct tax name.
+ */
+function isExistingTaxName(value: string, taxRates: TaxRates): boolean {
+ return !!Object.values(taxRates).find((taxRate) => taxRate.name === value);
+}
+
export {
meetsMinimumAgeRequirement,
meetsMaximumAgeRequirement,
@@ -498,4 +513,6 @@ export {
validateDateTimeIsAtLeastOneMinuteInFuture,
prepareValues,
isValidPersonName,
+ isValidPercentage,
+ isExistingTaxName,
};
diff --git a/src/libs/WorkspacesSettingsUtils.ts b/src/libs/WorkspacesSettingsUtils.ts
index a3a3d85419e2..78a8edb4c73f 100644
--- a/src/libs/WorkspacesSettingsUtils.ts
+++ b/src/libs/WorkspacesSettingsUtils.ts
@@ -5,7 +5,7 @@ import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import type {Policy, PolicyMembers, ReimbursementAccount, Report} from '@src/types/onyx';
import * as OptionsListUtils from './OptionsListUtils';
-import {hasCustomUnitsError, hasPolicyError, hasPolicyMemberError} from './PolicyUtils';
+import {hasCustomUnitsError, hasPolicyError, hasPolicyMemberError, hasTaxRateError} from './PolicyUtils';
import * as ReportActionsUtils from './ReportActionsUtils';
import * as ReportUtils from './ReportUtils';
@@ -80,6 +80,7 @@ function hasGlobalWorkspaceSettingsRBR(policies: OnyxCollection, policyM
const errorCheckingMethods: CheckingMethod[] = [
() => Object.values(cleanPolicies).some(hasPolicyError),
() => Object.values(cleanPolicies).some(hasCustomUnitsError),
+ () => Object.values(cleanPolicies).some(hasTaxRateError),
() => Object.values(cleanAllPolicyMembers).some(hasPolicyMemberError),
() => Object.keys(reimbursementAccount?.errors ?? {}).length > 0,
];
diff --git a/src/libs/actions/OnyxUpdateManager.ts b/src/libs/actions/OnyxUpdateManager.ts
index b4554f9461ce..9c6f30cc5e9e 100644
--- a/src/libs/actions/OnyxUpdateManager.ts
+++ b/src/libs/actions/OnyxUpdateManager.ts
@@ -82,7 +82,7 @@ export default () => {
previousUpdateIDFromServer,
lastUpdateIDAppliedToClient,
});
- canUnpauseQueuePromise = App.getMissingOnyxUpdates(lastUpdateIDAppliedToClient, lastUpdateIDFromServer);
+ canUnpauseQueuePromise = App.getMissingOnyxUpdates(lastUpdateIDAppliedToClient, previousUpdateIDFromServer);
}
canUnpauseQueuePromise.finally(() => {
diff --git a/src/libs/actions/Policy.ts b/src/libs/actions/Policy.ts
index 785bdc81dc85..4f20c927bbe5 100644
--- a/src/libs/actions/Policy.ts
+++ b/src/libs/actions/Policy.ts
@@ -70,6 +70,7 @@ import type {
PolicyCategories,
PolicyCategory,
PolicyMember,
+ PolicyTag,
PolicyTagList,
PolicyTags,
RecentlyUsedCategories,
@@ -79,7 +80,7 @@ import type {
ReportAction,
Transaction,
} from '@src/types/onyx';
-import type {Errors, PendingAction} from '@src/types/onyx/OnyxCommon';
+import type {Errors, OnyxValueWithOfflineFeedback, PendingAction} from '@src/types/onyx/OnyxCommon';
import type {OriginalMessageJoinPolicyChangeLog} from '@src/types/onyx/OriginalMessage';
import type {Attributes, CustomUnit, Rate, Unit} from '@src/types/onyx/Policy';
import type {OnyxData} from '@src/types/onyx/Request';
@@ -2715,7 +2716,7 @@ function setWorkspaceCategoryEnabled(policyID: string, categoriesToUpdate: Recor
acc[key] = {
...policyCategories[key],
...categoriesToUpdate[key],
- errors: ErrorUtils.getMicroSecondOnyxError('workspace.categories.genericFailureMessage'),
+ errors: ErrorUtils.getMicroSecondOnyxError('workspace.categories.updateFailureMessage'),
pendingFields: {
enabled: null,
},
@@ -2770,7 +2771,7 @@ function createPolicyCategory(policyID: string, categoryName: string) {
key: `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`,
value: {
[categoryName]: {
- errors: ErrorUtils.getMicroSecondOnyxError('workspace.categories.genericFailureMessage'),
+ errors: ErrorUtils.getMicroSecondOnyxError('workspace.categories.createFailureMessage'),
pendingAction: null,
},
},
@@ -2831,7 +2832,7 @@ function renamePolicyCategory(policyID: string, policyCategory: {oldName: string
...policyCategoryToUpdate,
name: policyCategory.oldName,
unencodedName: decodeURIComponent(policyCategory.oldName),
- errors: ErrorUtils.getMicroSecondOnyxError('workspace.categories.genericFailureMessage'),
+ errors: ErrorUtils.getMicroSecondOnyxError('workspace.categories.updateFailureMessage'),
pendingAction: null,
},
},
@@ -2848,7 +2849,7 @@ function renamePolicyCategory(policyID: string, policyCategory: {oldName: string
}
function createPolicyTag(policyID: string, tagName: string) {
- const tagListName = Object.keys(allPolicyTags?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`] ?? {})[0];
+ const policyTag = PolicyUtils.getTagLists(allPolicyTags?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`] ?? {})?.[0] ?? {};
const onyxData: OnyxData = {
optimisticData: [
@@ -2856,7 +2857,7 @@ function createPolicyTag(policyID: string, tagName: string) {
onyxMethod: Onyx.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`,
value: {
- [tagListName]: {
+ [policyTag.name]: {
tags: {
[tagName]: {
name: tagName,
@@ -2874,7 +2875,7 @@ function createPolicyTag(policyID: string, tagName: string) {
onyxMethod: Onyx.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`,
value: {
- [tagListName]: {
+ [policyTag.name]: {
tags: {
[tagName]: {
errors: null,
@@ -2890,7 +2891,7 @@ function createPolicyTag(policyID: string, tagName: string) {
onyxMethod: Onyx.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`,
value: {
- [tagListName]: {
+ [policyTag.name]: {
tags: {
[tagName]: {
errors: ErrorUtils.getMicroSecondOnyxError('workspace.tags.genericFailureMessage'),
@@ -2996,6 +2997,68 @@ function setWorkspaceTagEnabled(policyID: string, tagsToUpdate: Record>>>((acc, tagName) => {
+ acc[tagName] = {pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE};
+ return acc;
+ }, {}),
+ },
+ },
+ },
+ },
+ ],
+ successData: [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`,
+ value: {
+ [policyTag.name]: {
+ tags: {
+ ...tagsToDelete.reduce>>>((acc, tagName) => {
+ acc[tagName] = null;
+ return acc;
+ }, {}),
+ },
+ },
+ },
+ },
+ ],
+ failureData: [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`,
+ value: {
+ [policyTag.name]: {
+ tags: {
+ ...tagsToDelete.reduce>>>((acc, tagName) => {
+ acc[tagName] = {pendingAction: null, errors: ErrorUtils.getMicroSecondOnyxError('workspace.tags.deleteFailureMessage')};
+ return acc;
+ }, {}),
+ },
+ },
+ },
+ },
+ ],
+ };
+
+ const parameters = {
+ policyID,
+ tags: JSON.stringify(tagsToDelete),
+ };
+
+ API.write(WRITE_COMMANDS.DELETE_POLICY_TAGS, parameters, onyxData);
+}
+
function clearPolicyTagErrors(policyID: string, tagName: string) {
const tagListName = Object.keys(allPolicyTags?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`] ?? {})[0];
const tag = allPolicyTags?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`]?.[tagListName].tags?.[tagName];
@@ -3063,7 +3126,7 @@ function setWorkspaceRequiresCategory(policyID: string, requiresCategory: boolea
key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
value: {
requiresCategory: !requiresCategory,
- errors: ErrorUtils.getMicroSecondOnyxError('workspace.categories.genericFailureMessage'),
+ errors: ErrorUtils.getMicroSecondOnyxError('workspace.categories.updateFailureMessage'),
pendingFields: {
requiresCategory: null,
},
@@ -3836,6 +3899,162 @@ function clearCreateDistanceRateItemAndError(policyID: string, customUnitID: str
});
}
+function setPolicyCustomTaxName(policyID: string, customTaxName: string) {
+ const policy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`];
+ const originalCustomTaxName = policy?.taxRates?.name;
+ const onyxData: OnyxData = {
+ optimisticData: [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
+ value: {
+ taxRates: {
+ name: customTaxName,
+ pendingFields: {name: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE},
+ errorFields: null,
+ },
+ },
+ },
+ ],
+ successData: [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
+ value: {
+ taxRates: {
+ pendingFields: {name: null},
+ errorFields: null,
+ },
+ },
+ },
+ ],
+ failureData: [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
+ value: {
+ taxRates: {
+ name: originalCustomTaxName,
+ pendingFields: {name: null},
+ errorFields: {name: ErrorUtils.getMicroSecondOnyxError('common.genericErrorMessage')},
+ },
+ },
+ },
+ ],
+ };
+
+ const parameters = {
+ policyID,
+ customTaxName,
+ };
+
+ API.write(WRITE_COMMANDS.SET_POLICY_CUSTOM_TAX_NAME, parameters, onyxData);
+}
+
+function setWorkspaceCurrencyDefault(policyID: string, taxCode: string) {
+ const policy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`];
+ const originalDefaultExternalID = policy?.taxRates?.defaultExternalID;
+ const onyxData: OnyxData = {
+ optimisticData: [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
+ value: {
+ taxRates: {
+ defaultExternalID: taxCode,
+ pendingFields: {defaultExternalID: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE},
+ errorFields: null,
+ },
+ },
+ },
+ ],
+ successData: [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
+ value: {
+ taxRates: {
+ pendingFields: {defaultExternalID: null},
+ errorFields: null,
+ },
+ },
+ },
+ ],
+ failureData: [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
+ value: {
+ taxRates: {
+ defaultExternalID: originalDefaultExternalID,
+ pendingFields: {defaultExternalID: null},
+ errorFields: {defaultExternalID: ErrorUtils.getMicroSecondOnyxError('common.genericErrorMessage')},
+ },
+ },
+ },
+ ],
+ };
+
+ const parameters = {
+ policyID,
+ taxCode,
+ };
+
+ API.write(WRITE_COMMANDS.SET_POLICY_TAXES_CURRENCY_DEFAULT, parameters, onyxData);
+}
+
+function setForeignCurrencyDefault(policyID: string, taxCode: string) {
+ const policy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`];
+ const originalDefaultForeignCurrencyID = policy?.taxRates?.foreignTaxDefault;
+ const onyxData: OnyxData = {
+ optimisticData: [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
+ value: {
+ taxRates: {
+ foreignTaxDefault: taxCode,
+ pendingFields: {foreignTaxDefault: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE},
+ errorFields: null,
+ },
+ },
+ },
+ ],
+ successData: [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
+ value: {
+ taxRates: {
+ pendingFields: {foreignTaxDefault: null},
+ errorFields: null,
+ },
+ },
+ },
+ ],
+ failureData: [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
+ value: {
+ taxRates: {
+ foreignTaxDefault: originalDefaultForeignCurrencyID,
+ pendingFields: {foreignTaxDefault: null},
+ errorFields: {foreignTaxDefault: ErrorUtils.getMicroSecondOnyxError('common.genericErrorMessage')},
+ },
+ },
+ },
+ ],
+ };
+
+ const parameters = {
+ policyID,
+ taxCode,
+ };
+
+ API.write(WRITE_COMMANDS.SET_POLICY_TAXES_FOREIGN_CURRENCY_DEFAULT, parameters, onyxData);
+}
+
export {
removeMembers,
updateWorkspaceMembersRole,
@@ -3914,7 +4133,11 @@ export {
clearPolicyTagErrors,
clearWorkspaceReimbursementErrors,
deleteWorkspaceCategories,
+ deletePolicyTags,
setWorkspaceTagEnabled,
+ setWorkspaceCurrencyDefault,
+ setForeignCurrencyDefault,
+ setPolicyCustomTaxName,
clearPolicyErrorField,
hasCurrencySupportedForDirectReimbursement,
};
diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts
index f09b5c08b166..49ecfce36cf0 100644
--- a/src/libs/actions/Report.ts
+++ b/src/libs/actions/Report.ts
@@ -2883,6 +2883,17 @@ function clearNewRoomFormError() {
});
}
+function getReportDraftStatus(reportID: string) {
+ if (!allReports) {
+ return false;
+ }
+
+ if (!allReports[reportID]) {
+ return false;
+ }
+ return allReports[reportID]?.hasDraft;
+}
+
function resolveActionableMentionWhisper(reportId: string, reportAction: OnyxEntry, resolution: ValueOf) {
const message = reportAction?.message?.[0];
if (!message) {
@@ -2933,6 +2944,7 @@ function resolveActionableMentionWhisper(reportId: string, reportAction: OnyxEnt
}
export {
+ getReportDraftStatus,
searchInServer,
addComment,
addAttachment,
diff --git a/src/libs/actions/TaxRate.ts b/src/libs/actions/TaxRate.ts
new file mode 100644
index 000000000000..1bad1de0a9f5
--- /dev/null
+++ b/src/libs/actions/TaxRate.ts
@@ -0,0 +1,128 @@
+import Onyx from 'react-native-onyx';
+import * as API from '@libs/API';
+import type {CreatePolicyTaxParams} from '@libs/API/parameters';
+import {WRITE_COMMANDS} from '@libs/API/types';
+import CONST from '@src/CONST';
+import * as ErrorUtils from '@src/libs/ErrorUtils';
+import ONYXKEYS from '@src/ONYXKEYS';
+import type {TaxRate, TaxRates} from '@src/types/onyx';
+import type {PendingAction} from '@src/types/onyx/OnyxCommon';
+import type {OnyxData} from '@src/types/onyx/Request';
+
+/**
+ * Get tax value with percentage
+ */
+function getTaxValueWithPercentage(value: string): string {
+ return `${value}%`;
+}
+
+function covertTaxNameToID(name: string) {
+ return `id_${name.toUpperCase().replaceAll(' ', '_')}`;
+}
+
+/**
+ * Get new tax ID
+ */
+function getNextTaxCode(name: string, taxRates?: TaxRates): string {
+ const newID = covertTaxNameToID(name);
+ if (!taxRates?.[newID]) {
+ return newID;
+ }
+
+ // If the tax ID already exists, we need to find a unique ID
+ let nextID = 1;
+ while (taxRates?.[covertTaxNameToID(`${name}_${nextID}`)]) {
+ nextID++;
+ }
+ return covertTaxNameToID(`${name}_${nextID}`);
+}
+
+function createPolicyTax(policyID: string, taxRate: TaxRate) {
+ if (!taxRate.code) {
+ throw new Error('Tax code is required when creating a new tax rate.');
+ }
+
+ const onyxData: OnyxData = {
+ optimisticData: [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
+ value: {
+ taxRates: {
+ taxes: {
+ [taxRate.code]: {
+ ...taxRate,
+ pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD,
+ },
+ },
+ },
+ },
+ },
+ ],
+ successData: [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
+ value: {
+ taxRates: {
+ taxes: {
+ [taxRate.code]: {
+ errors: null,
+ pendingAction: null,
+ },
+ },
+ },
+ },
+ },
+ ],
+ failureData: [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
+ value: {
+ taxRates: {
+ taxes: {
+ [taxRate.code]: {
+ errors: ErrorUtils.getMicroSecondOnyxError('workspace.taxes.errors.genericFailureMessage'),
+ },
+ },
+ },
+ },
+ },
+ ],
+ };
+
+ const parameters = {
+ policyID,
+ taxFields: JSON.stringify({
+ name: taxRate.name,
+ value: taxRate.value,
+ enabled: true,
+ taxCode: taxRate.code,
+ }),
+ } satisfies CreatePolicyTaxParams;
+
+ API.write(WRITE_COMMANDS.CREATE_POLICY_TAX, parameters, onyxData);
+}
+
+function clearTaxRateError(policyID: string, taxID: string, pendingAction?: PendingAction) {
+ if (pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD) {
+ Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {
+ taxRates: {
+ taxes: {
+ [taxID]: null,
+ },
+ },
+ });
+ return;
+ }
+ Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {
+ taxRates: {
+ taxes: {
+ [taxID]: {pendingAction: null, errors: null},
+ },
+ },
+ });
+}
+
+export {createPolicyTax, clearTaxRateError, getNextTaxCode, getTaxValueWithPercentage};
diff --git a/src/pages/TeachersUnite/ImTeacherUpdateEmailPage.tsx b/src/pages/TeachersUnite/ImTeacherUpdateEmailPage.tsx
index 551568e7dbb1..58d011949e86 100644
--- a/src/pages/TeachersUnite/ImTeacherUpdateEmailPage.tsx
+++ b/src/pages/TeachersUnite/ImTeacherUpdateEmailPage.tsx
@@ -20,7 +20,7 @@ function ImTeacherUpdateEmailPage() {
Navigation.goBack(ROUTES.TEACHERS_UNITE)}
+ onBackButtonPress={() => Navigation.goBack()}
/>
Navigation.goBack(ROUTES.TEACHERS_UNITE)}
+ onBackButtonPress={() => Navigation.goBack()}
/>
Navigation.goBack(ROUTES.TEACHERS_UNITE)}
+ onBackButtonPress={() => Navigation.goBack()}
/>
value !== undefined && value !== '');
+}
+
function ReportScreen({
betas = [],
route,
@@ -349,8 +363,11 @@ function ReportScreen({
Performance.markEnd(CONST.TIMING.CHAT_RENDER);
fetchReportIfNeeded();
- ComposerActions.setShouldShowComposeInput(true);
+ const interactionTask = InteractionManager.runAfterInteractions(() => {
+ ComposerActions.setShouldShowComposeInput(true);
+ });
return () => {
+ interactionTask.cancel();
if (!didSubscribeToReportLeavingEvents) {
return;
}
@@ -394,7 +411,7 @@ function ReportScreen({
!onyxReportID &&
prevReport.statusNum === CONST.REPORT.STATUS_NUM.OPEN &&
(report.statusNum === CONST.REPORT.STATUS_NUM.CLOSED || (!report.statusNum && !prevReport.parentReportID && prevReport.chatType === CONST.REPORT.CHAT_TYPE.POLICY_ROOM))) ||
- ((ReportUtils.isMoneyRequest(prevReport) || ReportUtils.isMoneyRequestReport(prevReport)) && isEmptyObject(report))
+ ((ReportUtils.isMoneyRequest(prevReport) || ReportUtils.isMoneyRequestReport(prevReport) || ReportUtils.isPolicyExpenseChat(prevReport)) && isEmpty(report))
) {
Navigation.dismissModal();
if (Navigation.getTopmostReportId() === prevOnyxReportID) {
@@ -435,10 +452,19 @@ function ReportScreen({
// any `pendingFields.createChat` or `pendingFields.addWorkspaceRoom` fields are set to null.
// Existing reports created will have empty fields for `pendingFields`.
const didCreateReportSuccessfully = !report.pendingFields || (!report.pendingFields.addWorkspaceRoom && !report.pendingFields.createChat);
+ let interactionTask: ReturnType | null = null;
if (!didSubscribeToReportLeavingEvents.current && didCreateReportSuccessfully) {
- Report.subscribeToReportLeavingEvents(reportID);
- didSubscribeToReportLeavingEvents.current = true;
+ interactionTask = InteractionManager.runAfterInteractions(() => {
+ Report.subscribeToReportLeavingEvents(reportID);
+ didSubscribeToReportLeavingEvents.current = true;
+ });
}
+ return () => {
+ if (!interactionTask) {
+ return;
+ }
+ interactionTask.cancel();
+ };
}, [report, didSubscribeToReportLeavingEvents, reportID]);
const onListLayout = useCallback((event: LayoutChangeEvent) => {
@@ -529,8 +555,8 @@ function ReportScreen({
)}
{/* Note: The ReportActionsSkeletonView should be allowed to mount even if the initial report actions are not loaded.
- If we prevent rendering the report while they are loading then
- we'll unnecessarily unmount the ReportActionsView which will clear the new marker lines initial state. */}
+ If we prevent rendering the report while they are loading then
+ we'll unnecessarily unmount the ReportActionsView which will clear the new marker lines initial state. */}
{(!isReportReadyForDisplay || isLoadingInitialReportActions || isLoading) && }
{isReportReadyForDisplay ? (
@@ -542,9 +568,7 @@ function ReportScreen({
isEmptyChat={isEmptyChat}
lastReportAction={lastReportAction}
/>
- ) : (
-
- )}
+ ) : null}
diff --git a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx
index 6bbe5b10802f..cf3761a8d76a 100644
--- a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx
+++ b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx
@@ -419,8 +419,15 @@ function ComposerWithSuggestions(
Report.setReportWithDraft(reportID, true);
}
+ const hasDraftStatus = Report.getReportDraftStatus(reportID);
+
+ /**
+ * The extra `!hasDraftStatus` check is to prevent the draft being set
+ * when the user navigates to the ReportScreen. This doesn't alter anything
+ * in terms of functionality.
+ */
// The draft has been deleted.
- if (newCommentConverted.length === 0) {
+ if (newCommentConverted.length === 0 && hasDraftStatus) {
Report.setReportWithDraft(reportID, false);
}
@@ -676,13 +683,6 @@ function ComposerWithSuggestions(
useEffect(() => {
// Scrolls the composer to the bottom and sets the selection to the end, so that longer drafts are easier to edit
updateMultilineInputRange(textInputRef.current, !!shouldAutoFocus);
-
- if (value.length === 0) {
- return;
- }
-
- Report.setReportWithDraft(reportID, true);
-
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useImperativeHandle(
diff --git a/src/pages/home/report/ReportActionCompose/SilentCommentUpdater/index.tsx b/src/pages/home/report/ReportActionCompose/SilentCommentUpdater/index.tsx
index 1abc6567bc7b..f3780528cabe 100644
--- a/src/pages/home/report/ReportActionCompose/SilentCommentUpdater/index.tsx
+++ b/src/pages/home/report/ReportActionCompose/SilentCommentUpdater/index.tsx
@@ -17,7 +17,17 @@ function SilentCommentUpdater({comment, commentRef, reportID, value, updateComme
const prevPreferredLocale = usePrevious(preferredLocale);
useEffect(() => {
+ /**
+ * Schedules the callback to run when the main thread is idle.
+ */
+ if ('requestIdleCallback' in window) {
+ const callbackID = requestIdleCallback(() => {
+ updateComment(comment ?? '');
+ });
+ return () => cancelIdleCallback(callbackID);
+ }
updateComment(comment ?? '');
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- We need to run this on mount
}, []);
diff --git a/src/pages/home/report/ReportActionsList.tsx b/src/pages/home/report/ReportActionsList.tsx
index 8ba69d82491d..366b04634eb0 100644
--- a/src/pages/home/report/ReportActionsList.tsx
+++ b/src/pages/home/report/ReportActionsList.tsx
@@ -108,6 +108,8 @@ function isMessageUnread(message: OnyxTypes.ReportAction, lastReadTime?: string)
return !!(message && lastReadTime && message.created && lastReadTime < message.created);
}
+const onScrollToIndexFailed = () => {};
+
function ReportActionsList({
report,
parentReportAction,
@@ -305,7 +307,9 @@ function ReportActionsList({
if (unsubscribe) {
unsubscribe();
}
- Report.unsubscribeFromReportChannel(report.reportID);
+ InteractionManager.runAfterInteractions(() => {
+ Report.unsubscribeFromReportChannel(report.reportID);
+ });
};
newActionUnsubscribeMap[report.reportID] = cleanup;
@@ -481,7 +485,7 @@ function ReportActionsList({
// Native mobile does not render updates flatlist the changes even though component did update called.
// To notify there something changes we can use extraData prop to flatlist
- const extraData = [isSmallScreenWidth ? currentUnreadMarker : undefined, ReportUtils.isArchivedRoom(report)];
+ const extraData = useMemo(() => [isSmallScreenWidth ? currentUnreadMarker : undefined, ReportUtils.isArchivedRoom(report)], [currentUnreadMarker, isSmallScreenWidth, report]);
const hideComposer = !ReportUtils.canUserPerformWriteAction(report);
const shouldShowReportRecipientLocalTime = ReportUtils.canShowReportRecipientLocalTime(personalDetailsList, report, currentUserPersonalDetails.accountID) && !isComposerFullSize;
@@ -558,7 +562,7 @@ function ReportActionsList({
keyboardShouldPersistTaps="handled"
onLayout={onLayoutInner}
onScroll={trackVerticalScrolling}
- onScrollToIndexFailed={() => {}}
+ onScrollToIndexFailed={onScrollToIndexFailed}
extraData={extraData}
/>
diff --git a/src/pages/home/report/ReportActionsView.tsx b/src/pages/home/report/ReportActionsView.tsx
index ab3bda2fa8ca..3153fd1061ff 100755
--- a/src/pages/home/report/ReportActionsView.tsx
+++ b/src/pages/home/report/ReportActionsView.tsx
@@ -2,6 +2,7 @@ import {useIsFocused} from '@react-navigation/native';
import lodashIsEqual from 'lodash/isEqual';
import lodashThrottle from 'lodash/throttle';
import React, {useCallback, useContext, useEffect, useMemo, useRef} from 'react';
+import {InteractionManager} from 'react-native';
import {withOnyx} from 'react-native-onyx';
import type {OnyxEntry} from 'react-native-onyx';
import useCopySelectionHelper from '@hooks/useCopySelectionHelper';
@@ -90,7 +91,15 @@ function ReportActionsView({
};
useEffect(() => {
- openReportIfNecessary();
+ const interactionTask = InteractionManager.runAfterInteractions(() => {
+ openReportIfNecessary();
+ });
+ // eslint-disable-next-line @typescript-eslint/no-misused-promises
+ if (interactionTask) {
+ return () => {
+ interactionTask.cancel();
+ };
+ }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@@ -145,8 +154,13 @@ function ReportActionsView({
// Existing reports created will have empty fields for `pendingFields`.
const didCreateReportSuccessfully = !report.pendingFields || (!report.pendingFields.addWorkspaceRoom && !report.pendingFields.createChat);
if (!didSubscribeToReportTypingEvents.current && didCreateReportSuccessfully) {
- Report.subscribeToReportTypingEvents(reportID);
- didSubscribeToReportTypingEvents.current = true;
+ const interactionTask = InteractionManager.runAfterInteractions(() => {
+ Report.subscribeToReportTypingEvents(reportID);
+ didSubscribeToReportTypingEvents.current = true;
+ });
+ return () => {
+ interactionTask.cancel();
+ };
}
}, [report.pendingFields, didSubscribeToReportTypingEvents, reportID]);
diff --git a/src/pages/home/sidebar/AvatarWithOptionalStatus.js b/src/pages/home/sidebar/AvatarWithOptionalStatus.js
index 942d5c1da1f2..039a3c78503a 100644
--- a/src/pages/home/sidebar/AvatarWithOptionalStatus.js
+++ b/src/pages/home/sidebar/AvatarWithOptionalStatus.js
@@ -2,12 +2,9 @@
import PropTypes from 'prop-types';
import React from 'react';
import {View} from 'react-native';
-import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback';
import Text from '@components/Text';
-import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
-import CONST from '@src/CONST';
-import PressableAvatarWithIndicator from './PressableAvatarWithIndicator';
+import ProfileAvatarWithIndicator from './ProfileAvatarWithIndicator';
const propTypes = {
/** Emoji status */
@@ -15,40 +12,27 @@ const propTypes = {
/** Whether the avatar is selected */
isSelected: PropTypes.bool,
-
- /** Callback called when the avatar or status icon is pressed */
- onPress: PropTypes.func,
};
const defaultProps = {
emojiStatus: '',
isSelected: false,
- onPress: () => {},
};
-function AvatarWithOptionalStatus({emojiStatus, isSelected, onPress}) {
+function AvatarWithOptionalStatus({emojiStatus, isSelected}) {
const styles = useThemeStyles();
- const {translate} = useLocalize();
return (
-
-
+
+
{emojiStatus}
-
+
);
}
diff --git a/src/pages/home/sidebar/BottomTabAvatar.tsx b/src/pages/home/sidebar/BottomTabAvatar.tsx
index 15134b762161..63abcf063b5f 100644
--- a/src/pages/home/sidebar/BottomTabAvatar.tsx
+++ b/src/pages/home/sidebar/BottomTabAvatar.tsx
@@ -1,11 +1,16 @@
/* eslint-disable rulesdir/onyx-props-must-have-default */
import React, {useCallback} from 'react';
+import {PressableWithFeedback} from '@components/Pressable';
+import Tooltip from '@components/Tooltip';
import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails';
+import useLocalize from '@hooks/useLocalize';
+import useThemeStyles from '@hooks/useThemeStyles';
import interceptAnonymousUser from '@libs/interceptAnonymousUser';
import Navigation from '@libs/Navigation/Navigation';
+import CONST from '@src/CONST';
import ROUTES from '@src/ROUTES';
import AvatarWithOptionalStatus from './AvatarWithOptionalStatus';
-import PressableAvatarWithIndicator from './PressableAvatarWithIndicator';
+import ProfileAvatarWithIndicator from './ProfileAvatarWithIndicator';
type BottomTabAvatarProps = {
/** Whether the create menu is open or not */
@@ -16,6 +21,8 @@ type BottomTabAvatarProps = {
};
function BottomTabAvatar({isCreateMenuOpen = false, isSelected = false}: BottomTabAvatarProps) {
+ const styles = useThemeStyles();
+ const {translate} = useLocalize();
const currentUserPersonalDetails = useCurrentUserPersonalDetails();
const emojiStatus = currentUserPersonalDetails?.status?.emojiCode ?? '';
@@ -28,20 +35,31 @@ function BottomTabAvatar({isCreateMenuOpen = false, isSelected = false}: BottomT
interceptAnonymousUser(() => Navigation.navigate(ROUTES.SETTINGS));
}, [isCreateMenuOpen]);
+ let children;
+
if (emojiStatus) {
- return (
+ children = (
);
+ } else {
+ children = ;
}
+
return (
-
+
+
+ {children}
+
+
);
}
diff --git a/src/pages/home/sidebar/PressableAvatarWithIndicator.js b/src/pages/home/sidebar/PressableAvatarWithIndicator.js
deleted file mode 100644
index a7345ff6c14a..000000000000
--- a/src/pages/home/sidebar/PressableAvatarWithIndicator.js
+++ /dev/null
@@ -1,78 +0,0 @@
-/* eslint-disable rulesdir/onyx-props-must-have-default */
-import lodashGet from 'lodash/get';
-import PropTypes from 'prop-types';
-import React from 'react';
-import {View} from 'react-native';
-import {withOnyx} from 'react-native-onyx';
-import AvatarWithIndicator from '@components/AvatarWithIndicator';
-import OfflineWithFeedback from '@components/OfflineWithFeedback';
-import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback';
-import withCurrentUserPersonalDetails from '@components/withCurrentUserPersonalDetails';
-import useLocalize from '@hooks/useLocalize';
-import useThemeStyles from '@hooks/useThemeStyles';
-import compose from '@libs/compose';
-import * as UserUtils from '@libs/UserUtils';
-import personalDetailsPropType from '@pages/personalDetailsPropType';
-import CONST from '@src/CONST';
-import ONYXKEYS from '@src/ONYXKEYS';
-
-const propTypes = {
- /** The personal details of the person who is logged in */
- currentUserPersonalDetails: personalDetailsPropType,
-
- /** Indicates whether the app is loading initial data */
- isLoading: PropTypes.bool,
-
- /** Whether the avatar is selected */
- isSelected: PropTypes.bool,
-
- /** Callback called when the avatar is pressed */
- onPress: PropTypes.func,
-};
-
-const defaultProps = {
- currentUserPersonalDetails: {
- pendingFields: {avatar: ''},
- accountID: '',
- avatar: '',
- },
- isLoading: true,
- isSelected: false,
- onPress: () => {},
-};
-
-function PressableAvatarWithIndicator({currentUserPersonalDetails, isLoading, isSelected, onPress}) {
- const {translate} = useLocalize();
- const styles = useThemeStyles();
-
- return (
-
-
-
-
-
-
-
- );
-}
-
-PressableAvatarWithIndicator.propTypes = propTypes;
-PressableAvatarWithIndicator.defaultProps = defaultProps;
-PressableAvatarWithIndicator.displayName = 'PressableAvatarWithIndicator';
-export default compose(
- withCurrentUserPersonalDetails,
- withOnyx({
- isLoading: {
- key: ONYXKEYS.IS_LOADING_APP,
- },
- }),
-)(PressableAvatarWithIndicator);
diff --git a/src/pages/home/sidebar/ProfileAvatarWithIndicator.js b/src/pages/home/sidebar/ProfileAvatarWithIndicator.js
new file mode 100644
index 000000000000..bd9c01aba001
--- /dev/null
+++ b/src/pages/home/sidebar/ProfileAvatarWithIndicator.js
@@ -0,0 +1,63 @@
+/* eslint-disable rulesdir/onyx-props-must-have-default */
+import lodashGet from 'lodash/get';
+import PropTypes from 'prop-types';
+import React from 'react';
+import {View} from 'react-native';
+import {withOnyx} from 'react-native-onyx';
+import AvatarWithIndicator from '@components/AvatarWithIndicator';
+import OfflineWithFeedback from '@components/OfflineWithFeedback';
+import withCurrentUserPersonalDetails from '@components/withCurrentUserPersonalDetails';
+import useThemeStyles from '@hooks/useThemeStyles';
+import compose from '@libs/compose';
+import * as UserUtils from '@libs/UserUtils';
+import personalDetailsPropType from '@pages/personalDetailsPropType';
+import ONYXKEYS from '@src/ONYXKEYS';
+
+const propTypes = {
+ /** The personal details of the person who is logged in */
+ currentUserPersonalDetails: personalDetailsPropType,
+
+ /** Indicates whether the app is loading initial data */
+ isLoading: PropTypes.bool,
+
+ /** Whether the avatar is selected */
+ isSelected: PropTypes.bool,
+};
+
+const defaultProps = {
+ currentUserPersonalDetails: {
+ pendingFields: {avatar: ''},
+ accountID: '',
+ avatar: '',
+ },
+ isLoading: true,
+ isSelected: false,
+};
+
+function ProfileAvatarWithIndicator({currentUserPersonalDetails, isLoading, isSelected}) {
+ const styles = useThemeStyles();
+
+ return (
+
+
+
+
+
+ );
+}
+
+ProfileAvatarWithIndicator.propTypes = propTypes;
+ProfileAvatarWithIndicator.defaultProps = defaultProps;
+ProfileAvatarWithIndicator.displayName = 'ProfileAvatarWithIndicator';
+export default compose(
+ withCurrentUserPersonalDetails,
+ withOnyx({
+ isLoading: {
+ key: ONYXKEYS.IS_LOADING_APP,
+ },
+ }),
+)(ProfileAvatarWithIndicator);
diff --git a/src/pages/home/sidebar/SidebarLinks.js b/src/pages/home/sidebar/SidebarLinks.js
index 4d1585cd424a..3716d6ef2f4e 100644
--- a/src/pages/home/sidebar/SidebarLinks.js
+++ b/src/pages/home/sidebar/SidebarLinks.js
@@ -1,6 +1,6 @@
/* eslint-disable rulesdir/onyx-props-must-have-default */
import PropTypes from 'prop-types';
-import React, {useCallback, useEffect, useMemo, useRef} from 'react';
+import React, {memo, useCallback, useEffect, useMemo, useRef} from 'react';
import {InteractionManager, StyleSheet, View} from 'react-native';
import _ from 'underscore';
import LHNOptionsList from '@components/LHNOptionsList/LHNOptionsList';
@@ -149,5 +149,5 @@ function SidebarLinks({onLinkClick, insets, optionListItems, isLoading, priority
SidebarLinks.propTypes = propTypes;
SidebarLinks.displayName = 'SidebarLinks';
-export default SidebarLinks;
+export default memo(SidebarLinks);
export {basePropTypes};
diff --git a/src/pages/iou/request/step/IOURequestStepScan/index.native.js b/src/pages/iou/request/step/IOURequestStepScan/index.native.js
index 338444d473c6..8dc8c634508c 100644
--- a/src/pages/iou/request/step/IOURequestStepScan/index.native.js
+++ b/src/pages/iou/request/step/IOURequestStepScan/index.native.js
@@ -1,6 +1,7 @@
+import {useFocusEffect} from '@react-navigation/core';
import lodashGet from 'lodash/get';
-import React, {useCallback, useEffect, useRef, useState} from 'react';
-import {ActivityIndicator, Alert, AppState, View} from 'react-native';
+import React, {useCallback, useRef, useState} from 'react';
+import {ActivityIndicator, Alert, AppState, InteractionManager, View} from 'react-native';
import {Gesture, GestureDetector} from 'react-native-gesture-handler';
import {RESULTS} from 'react-native-permissions';
import Animated, {runOnJS, useAnimatedStyle, useSharedValue, withDelay, withSequence, withSpring, withTiming} from 'react-native-reanimated';
@@ -120,37 +121,41 @@ function IOURequestStepScan({
runOnJS(focusCamera)(point);
});
- useEffect(() => {
- const refreshCameraPermissionStatus = (shouldAskForPermission = false) => {
- CameraPermission.getCameraPermissionStatus()
- .then((res) => {
- // In android device app data, the status is not set to blocked until denied twice,
- // due to that the app will ask for permission twice whenever users opens uses the scan tab
- setCameraPermissionStatus(res);
- if (shouldAskForPermission && !askedForPermission.current) {
- askedForPermission.current = true;
- askForPermissions(false);
- }
- })
- .catch(() => setCameraPermissionStatus(RESULTS.UNAVAILABLE));
- };
-
- // Check initial camera permission status
- refreshCameraPermissionStatus(true);
+ useFocusEffect(
+ useCallback(() => {
+ const refreshCameraPermissionStatus = (shouldAskForPermission = false) => {
+ CameraPermission.getCameraPermissionStatus()
+ .then((res) => {
+ // In android device app data, the status is not set to blocked until denied twice,
+ // due to that the app will ask for permission twice whenever users opens uses the scan tab
+ setCameraPermissionStatus(res);
+ if (shouldAskForPermission && !askedForPermission.current) {
+ askedForPermission.current = true;
+ askForPermissions(false);
+ }
+ })
+ .catch(() => setCameraPermissionStatus(RESULTS.UNAVAILABLE));
+ };
+
+ InteractionManager.runAfterInteractions(() => {
+ // Check initial camera permission status
+ refreshCameraPermissionStatus(true);
+ });
- // Refresh permission status when app gain focus
- const subscription = AppState.addEventListener('change', (appState) => {
- if (appState !== 'active') {
- return;
- }
+ // Refresh permission status when app gain focus
+ const subscription = AppState.addEventListener('change', (appState) => {
+ if (appState !== 'active') {
+ return;
+ }
- refreshCameraPermissionStatus();
- });
+ refreshCameraPermissionStatus();
+ });
- return () => {
- subscription.remove();
- };
- }, []);
+ return () => {
+ subscription.remove();
+ };
+ }, []),
+ );
const validateReceipt = (file) => {
const {fileExtension} = FileUtils.splitExtensionFromFileName(lodashGet(file, 'name', ''));
diff --git a/src/pages/iou/request/step/IOURequestStepTaxRatePage.js b/src/pages/iou/request/step/IOURequestStepTaxRatePage.js
index 0cc2375b5510..335964adf309 100644
--- a/src/pages/iou/request/step/IOURequestStepTaxRatePage.js
+++ b/src/pages/iou/request/step/IOURequestStepTaxRatePage.js
@@ -59,8 +59,7 @@ function IOURequestStepTaxRatePage({
};
const taxRates = lodashGet(policy, 'taxRates', {});
const defaultTaxKey = taxRates.defaultExternalID;
- const defaultTaxName = (defaultTaxKey && `${taxRates.taxes[defaultTaxKey].name} (${taxRates.taxes[defaultTaxKey].value}) • ${translate('common.default')}`) || '';
- const selectedTaxRate = (transaction.taxRate && transaction.taxRate.text) || defaultTaxName;
+ const selectedTaxRate = (transaction.taxRate && transaction.taxRate.keyForList) || defaultTaxKey;
const updateTaxRates = (taxes) => {
const taxAmount = getTaxAmount(taxRates, taxes.text, transaction.amount);
diff --git a/src/pages/settings/AboutPage/TroubleshootPage.tsx b/src/pages/settings/AboutPage/TroubleshootPage.tsx
index 9bc756df03cb..e04a834c6341 100644
--- a/src/pages/settings/AboutPage/TroubleshootPage.tsx
+++ b/src/pages/settings/AboutPage/TroubleshootPage.tsx
@@ -95,7 +95,7 @@ function TroubleshootPage({shouldStoreLogs}: TroubleshootPageProps) {
return (
Navigation.goBack(ROUTES.SETTINGS_ABOUT)}
+ onBackButtonPress={() => Navigation.goBack()}
backgroundColor={theme.PAGE_THEMES[SCREENS.SETTINGS.TROUBLESHOOT].backgroundColor}
illustration={LottieAnimations.Desk}
testID={TroubleshootPage.displayName}
diff --git a/src/pages/settings/ExitSurvey/ExitSurveyConfirmPage.tsx b/src/pages/settings/ExitSurvey/ExitSurveyConfirmPage.tsx
index 66b7ce13f568..eca0237eea02 100644
--- a/src/pages/settings/ExitSurvey/ExitSurveyConfirmPage.tsx
+++ b/src/pages/settings/ExitSurvey/ExitSurveyConfirmPage.tsx
@@ -62,7 +62,7 @@ function ExitSurveyConfirmPage({exitReason, isLoading, route, navigation}: ExitS
Navigation.goBack(backTo)}
+ onBackButtonPress={() => Navigation.goBack()}
/>
{isOffline && }
diff --git a/src/pages/settings/ExitSurvey/ExitSurveyResponsePage.tsx b/src/pages/settings/ExitSurvey/ExitSurveyResponsePage.tsx
index c43ef8dd9320..724c665b131a 100644
--- a/src/pages/settings/ExitSurvey/ExitSurveyResponsePage.tsx
+++ b/src/pages/settings/ExitSurvey/ExitSurveyResponsePage.tsx
@@ -98,7 +98,7 @@ function ExitSurveyResponsePage({draftResponse, route, navigation}: ExitSurveyRe
Navigation.goBack(backTo)}
+ onBackButtonPress={() => Navigation.goBack()}
/>
Navigation.goBack(navigateBackTo)}
+ onBackButtonPress={() => Navigation.goBack()}
/>
diff --git a/src/pages/settings/Wallet/ExpensifyCardPage.tsx b/src/pages/settings/Wallet/ExpensifyCardPage.tsx
index 732a21bc993c..4c8b02eabdc6 100644
--- a/src/pages/settings/Wallet/ExpensifyCardPage.tsx
+++ b/src/pages/settings/Wallet/ExpensifyCardPage.tsx
@@ -125,7 +125,7 @@ function ExpensifyCardPage({
<>
Navigation.goBack(ROUTES.SETTINGS_WALLET)}
+ onBackButtonPress={() => Navigation.goBack()}
/>
diff --git a/src/pages/workspace/WorkspaceInitialPage.tsx b/src/pages/workspace/WorkspaceInitialPage.tsx
index 871bb2cf7980..9a56a2da0314 100644
--- a/src/pages/workspace/WorkspaceInitialPage.tsx
+++ b/src/pages/workspace/WorkspaceInitialPage.tsx
@@ -194,6 +194,7 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, policyMembers, r
icon: Expensicons.Tax,
action: singleExecution(waitForNavigate(() => Navigation.navigate(ROUTES.WORKSPACE_TAXES.getRoute(policyID)))),
routeName: SCREENS.WORKSPACE.TAXES,
+ brickRoadIndicator: PolicyUtils.hasTaxRateError(policy) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined,
});
}
diff --git a/src/pages/workspace/categories/CategoryForm.tsx b/src/pages/workspace/categories/CategoryForm.tsx
index aaf954f64468..304f4153df8c 100644
--- a/src/pages/workspace/categories/CategoryForm.tsx
+++ b/src/pages/workspace/categories/CategoryForm.tsx
@@ -80,7 +80,6 @@ function CategoryForm({onSubmit, policyCategories, categoryName}: CategoryFormPr
accessibilityLabel={translate('common.name')}
inputID={INPUT_IDS.CATEGORY_NAME}
role={CONST.ROLE.PRESENTATION}
- autoFocus
/>
);
diff --git a/src/pages/workspace/categories/CategorySettingsPage.tsx b/src/pages/workspace/categories/CategorySettingsPage.tsx
index 7300f7c5a9d4..1fa3fdcdd042 100644
--- a/src/pages/workspace/categories/CategorySettingsPage.tsx
+++ b/src/pages/workspace/categories/CategorySettingsPage.tsx
@@ -1,9 +1,11 @@
import type {StackScreenProps} from '@react-navigation/stack';
-import React from 'react';
+import React, {useState} from 'react';
import {View} from 'react-native';
import {withOnyx} from 'react-native-onyx';
import type {OnyxEntry} from 'react-native-onyx';
+import ConfirmModal from '@components/ConfirmModal';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
+import * as Expensicons from '@components/Icon/Expensicons';
import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription';
import OfflineWithFeedback from '@components/OfflineWithFeedback';
import ScreenWrapper from '@components/ScreenWrapper';
@@ -11,6 +13,7 @@ import Switch from '@components/Switch';
import Text from '@components/Text';
import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
+import useWindowDimensions from '@hooks/useWindowDimensions';
import {setWorkspaceCategoryEnabled} from '@libs/actions/Policy';
import * as ErrorUtils from '@libs/ErrorUtils';
import Navigation from '@libs/Navigation/Navigation';
@@ -34,6 +37,8 @@ type CategorySettingsPageProps = CategorySettingsPageOnyxProps & StackScreenProp
function CategorySettingsPage({route, policyCategories}: CategorySettingsPageProps) {
const styles = useThemeStyles();
const {translate} = useLocalize();
+ const {windowWidth} = useWindowDimensions();
+ const [deleteCategoryConfirmModalVisible, setDeleteCategoryConfirmModalVisible] = useState(false);
const policyCategory = policyCategories?.[route.params.categoryName];
@@ -49,6 +54,20 @@ function CategorySettingsPage({route, policyCategories}: CategorySettingsPagePro
Navigation.navigate(ROUTES.WORKSPACE_CATEGORY_EDIT.getRoute(route.params.policyID, policyCategory.name));
};
+ const deleteCategory = () => {
+ Policy.deleteWorkspaceCategories(route.params.policyID, [route.params.categoryName]);
+ setDeleteCategoryConfirmModalVisible(false);
+ Navigation.dismissModal();
+ };
+
+ const threeDotsMenuItems = [
+ {
+ icon: Expensicons.Trashcan,
+ text: translate('workspace.categories.deleteCategory'),
+ onSelected: () => setDeleteCategoryConfirmModalVisible(true),
+ },
+ ];
+
return (
@@ -57,7 +76,22 @@ function CategorySettingsPage({route, policyCategories}: CategorySettingsPagePro
style={[styles.defaultModalContainer]}
testID={CategorySettingsPage.displayName}
>
-
+
+ setDeleteCategoryConfirmModalVisible(false)}
+ title={translate('workspace.categories.deleteCategory')}
+ prompt={translate('workspace.categories.deleteCategoryPrompt')}
+ confirmText={translate('common.delete')}
+ cancelText={translate('common.cancel')}
+ danger
+ />
Navigation.goBack(ROUTES.WORKSPACE_CATEGORY_SETTINGS.getRoute(route.params.policyID, route.params.categoryName))}
/>
>({});
const dropdownButtonRef = useRef(null);
+ const [deleteCategoriesConfirmModalVisible, setDeleteCategoriesConfirmModalVisible] = useState(false);
function fetchCategories() {
Policy.openPolicyCategoriesPage(route.params.policyID);
@@ -155,24 +157,27 @@ function WorkspaceCategoriesPage({policy, policyCategories, route}: WorkspaceCat
const selectedCategoriesArray = Object.keys(selectedCategories).filter((key) => selectedCategories[key]);
+ const handleDeleteCategories = () => {
+ setSelectedCategories({});
+ deleteWorkspaceCategories(route.params.policyID, selectedCategoriesArray);
+ setDeleteCategoriesConfirmModalVisible(false);
+ };
+
const getHeaderButtons = () => {
const options: Array>> = [];
if (selectedCategoriesArray.length > 0) {
options.push({
icon: Expensicons.Trashcan,
- text: translate('workspace.categories.deleteCategories'),
+ text: translate(selectedCategoriesArray.length === 1 ? 'workspace.categories.deleteCategory' : 'workspace.categories.deleteCategories'),
value: CONST.POLICY.CATEGORIES_BULK_ACTION_TYPES.DELETE,
- onSelected: () => {
- setSelectedCategories({});
- deleteWorkspaceCategories(route.params.policyID, selectedCategoriesArray);
- },
+ onSelected: () => setDeleteCategoriesConfirmModalVisible(true),
});
- const enabledCategories = selectedCategoriesArray.filter((categoryName) => policyCategories?.[categoryName].enabled);
+ const enabledCategories = selectedCategoriesArray.filter((categoryName) => policyCategories?.[categoryName]?.enabled);
if (enabledCategories.length > 0) {
const categoriesToDisable = selectedCategoriesArray
- .filter((categoryName) => policyCategories?.[categoryName].enabled)
+ .filter((categoryName) => policyCategories?.[categoryName]?.enabled)
.reduce>((acc, categoryName) => {
acc[categoryName] = {
name: categoryName,
@@ -183,7 +188,7 @@ function WorkspaceCategoriesPage({policy, policyCategories, route}: WorkspaceCat
options.push({
icon: Expensicons.DocumentSlash,
- text: translate('workspace.categories.disableCategories'),
+ text: translate(enabledCategories.length === 1 ? 'workspace.categories.disableCategory' : 'workspace.categories.disableCategories'),
value: CONST.POLICY.CATEGORIES_BULK_ACTION_TYPES.DISABLE,
onSelected: () => {
setSelectedCategories({});
@@ -192,10 +197,10 @@ function WorkspaceCategoriesPage({policy, policyCategories, route}: WorkspaceCat
});
}
- const disabledCategories = selectedCategoriesArray.filter((categoryName) => !policyCategories?.[categoryName].enabled);
+ const disabledCategories = selectedCategoriesArray.filter((categoryName) => !policyCategories?.[categoryName]?.enabled);
if (disabledCategories.length > 0) {
const categoriesToEnable = selectedCategoriesArray
- .filter((categoryName) => !policyCategories?.[categoryName].enabled)
+ .filter((categoryName) => !policyCategories?.[categoryName]?.enabled)
.reduce>((acc, categoryName) => {
acc[categoryName] = {
name: categoryName,
@@ -205,7 +210,7 @@ function WorkspaceCategoriesPage({policy, policyCategories, route}: WorkspaceCat
}, {});
options.push({
icon: Expensicons.Document,
- text: translate('workspace.categories.enableCategories'),
+ text: translate(disabledCategories.length === 1 ? 'workspace.categories.enableCategory' : 'workspace.categories.enableCategories'),
value: CONST.POLICY.CATEGORIES_BULK_ACTION_TYPES.ENABLE,
onSelected: () => {
setSelectedCategories({});
@@ -253,6 +258,8 @@ function WorkspaceCategoriesPage({policy, policyCategories, route}: WorkspaceCat
const isLoading = !isOffline && policyCategories === undefined;
+ const shouldShowEmptyState = !categoryList.some((category) => category.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) && !isLoading;
+
return (
@@ -269,6 +276,16 @@ function WorkspaceCategoriesPage({policy, policyCategories, route}: WorkspaceCat
>
{!isSmallScreenWidth && getHeaderButtons()}
+ setDeleteCategoriesConfirmModalVisible(false)}
+ title={translate(selectedCategoriesArray.length === 1 ? 'workspace.categories.deleteCategory' : 'workspace.categories.deleteCategories')}
+ prompt={translate(selectedCategoriesArray.length === 1 ? 'workspace.categories.deleteCategoryPrompt' : 'workspace.categories.deleteCategoriesPrompt')}
+ confirmText={translate('common.delete')}
+ cancelText={translate('common.cancel')}
+ danger
+ />
{isSmallScreenWidth && {getHeaderButtons()}}
{translate('workspace.categories.subtitle')}
@@ -280,14 +297,14 @@ function WorkspaceCategoriesPage({policy, policyCategories, route}: WorkspaceCat
color={theme.spinner}
/>
)}
- {categoryList.length === 0 && !isLoading && (
+ {shouldShowEmptyState && (
)}
- {categoryList.length > 0 && (
+ {!shouldShowEmptyState && (
diff --git a/src/pages/workspace/tags/TagSettingsPage.tsx b/src/pages/workspace/tags/TagSettingsPage.tsx
index 14ba54e09f7e..6e6694d1dc39 100644
--- a/src/pages/workspace/tags/TagSettingsPage.tsx
+++ b/src/pages/workspace/tags/TagSettingsPage.tsx
@@ -3,7 +3,9 @@ import React, {useMemo} from 'react';
import {View} from 'react-native';
import {withOnyx} from 'react-native-onyx';
import type {OnyxEntry} from 'react-native-onyx';
+import ConfirmModal from '@components/ConfirmModal';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
+import {Trashcan} from '@components/Icon/Expensicons';
import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription';
import OfflineWithFeedback from '@components/OfflineWithFeedback';
import ScreenWrapper from '@components/ScreenWrapper';
@@ -11,8 +13,10 @@ import Switch from '@components/Switch';
import Text from '@components/Text';
import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
+import useWindowDimensions from '@hooks/useWindowDimensions';
import {setWorkspaceTagEnabled} from '@libs/actions/Policy';
import * as ErrorUtils from '@libs/ErrorUtils';
+import Navigation from '@libs/Navigation/Navigation';
import * as PolicyUtils from '@libs/PolicyUtils';
import type {SettingsNavigatorParamList} from '@navigation/types';
import NotFoundPage from '@pages/ErrorPage/NotFoundPage';
@@ -35,12 +39,22 @@ function TagSettingsPage({route, policyTags}: TagSettingsPageProps) {
const {translate} = useLocalize();
const policyTag = useMemo(() => PolicyUtils.getTagList(policyTags, 0), [policyTags]);
+ const {windowWidth} = useWindowDimensions();
+
+ const [isDeleteTagModalOpen, setIsDeleteTagModalOpen] = React.useState(false);
+
const currentPolicyTag = policyTag.tags[decodeURIComponent(route.params.tagName)];
if (!currentPolicyTag) {
return ;
}
+ const deleteTagAndHideModal = () => {
+ Policy.deletePolicyTags(route.params.policyID, [currentPolicyTag.name]);
+ setIsDeleteTagModalOpen(false);
+ Navigation.goBack();
+ };
+
const updateWorkspaceTagEnabled = (value: boolean) => {
setWorkspaceTagEnabled(route.params.policyID, {[currentPolicyTag.name]: {name: currentPolicyTag.name, enabled: value}});
};
@@ -53,7 +67,30 @@ function TagSettingsPage({route, policyTags}: TagSettingsPageProps) {
style={[styles.defaultModalContainer]}
testID={TagSettingsPage.displayName}
>
-
+ setIsDeleteTagModalOpen(true),
+ },
+ ]}
+ />
+ setIsDeleteTagModalOpen(false)}
+ shouldSetModalVisibility={false}
+ prompt={translate('workspace.tags.deleteTagConfirmation')}
+ confirmText={translate('common.delete')}
+ cancelText={translate('common.cancel')}
+ danger
+ />
;
+
+function WorkspaceCreateTaxPage({
+ policy,
+ route: {
+ params: {policyID},
+ },
+}: WorkspaceCreateTaxPageProps) {
+ const styles = useThemeStyles();
+ const {translate} = useLocalize();
+
+ const validate = useCallback(
+ (values: FormOnyxValues): FormInputErrors => {
+ const errors = ValidationUtils.getFieldRequiredErrors(values, [INPUT_IDS.VALUE, INPUT_IDS.NAME]);
+
+ const value = values[INPUT_IDS.VALUE];
+ if (!ValidationUtils.isValidPercentage(value)) {
+ errors[INPUT_IDS.VALUE] = 'workspace.taxes.errors.valuePercentageRange';
+ }
+
+ const name = values[INPUT_IDS.NAME];
+ if (policy?.taxRates?.taxes && ValidationUtils.isExistingTaxName(name, policy.taxRates.taxes)) {
+ errors[INPUT_IDS.NAME] = 'workspace.taxes.errors.taxRateAlreadyExists';
+ }
+
+ return errors;
+ },
+ [policy?.taxRates?.taxes],
+ );
+
+ const submitForm = useCallback(
+ ({value, ...values}: FormOnyxValues) => {
+ const taxRate = {
+ ...values,
+ value: getTaxValueWithPercentage(value),
+ code: getNextTaxCode(values[INPUT_IDS.NAME], policy?.taxRates?.taxes),
+ } satisfies TaxRate;
+ createPolicyTax(policyID, taxRate);
+ Navigation.goBack();
+ },
+ [policy?.taxRates?.taxes, policyID],
+ );
+
+ return (
+
+
+
+
+
+
+
+
+ (v ? getTaxValueWithPercentage(v) : '')}
+ description={translate('workspace.taxes.value')}
+ rightLabel={translate('common.required')}
+ hideCurrencySymbol
+ extraSymbol={%}
+ />
+
+
+
+
+
+
+ );
+}
+
+WorkspaceCreateTaxPage.displayName = 'WorkspaceCreateTaxPage';
+
+export default withPolicyAndFullscreenLoading(WorkspaceCreateTaxPage);
diff --git a/src/pages/workspace/taxes/WorkspaceTaxesPage.tsx b/src/pages/workspace/taxes/WorkspaceTaxesPage.tsx
index 18123d109645..d0724f4592ba 100644
--- a/src/pages/workspace/taxes/WorkspaceTaxesPage.tsx
+++ b/src/pages/workspace/taxes/WorkspaceTaxesPage.tsx
@@ -17,12 +17,15 @@ import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
import useWindowDimensions from '@hooks/useWindowDimensions';
import {openPolicyTaxesPage} from '@libs/actions/Policy';
+import {clearTaxRateError} from '@libs/actions/TaxRate';
+import Navigation from '@libs/Navigation/Navigation';
import type {WorkspacesCentralPaneNavigatorParamList} from '@navigation/types';
import AdminPolicyAccessOrNotFoundWrapper from '@pages/workspace/AdminPolicyAccessOrNotFoundWrapper';
import PaidPolicyAccessOrNotFoundWrapper from '@pages/workspace/PaidPolicyAccessOrNotFoundWrapper';
import withPolicyAndFullscreenLoading from '@pages/workspace/withPolicyAndFullscreenLoading';
import type {WithPolicyAndFullscreenLoadingProps} from '@pages/workspace/withPolicyAndFullscreenLoading';
import CONST from '@src/CONST';
+import ROUTES from '@src/ROUTES';
import type SCREENS from '@src/SCREENS';
type WorkspaceTaxesPageProps = WithPolicyAndFullscreenLoadingProps & StackScreenProps;
@@ -72,6 +75,8 @@ function WorkspaceTaxesPage({policy, route}: WorkspaceTaxesPageProps) {
keyForList: key,
isSelected: !!selectedTaxesIDs.includes(key),
isDisabledCheckbox: key === defaultExternalID,
+ pendingAction: value.pendingAction,
+ errors: value.errors,
rightElement: (
@@ -129,14 +134,14 @@ function WorkspaceTaxesPage({policy, route}: WorkspaceTaxesPageProps) {
diff --git a/src/pages/workspace/taxes/WorkspaceTaxesSettingsCustomTaxName.tsx b/src/pages/workspace/taxes/WorkspaceTaxesSettingsCustomTaxName.tsx
new file mode 100644
index 000000000000..892434ce2d52
--- /dev/null
+++ b/src/pages/workspace/taxes/WorkspaceTaxesSettingsCustomTaxName.tsx
@@ -0,0 +1,84 @@
+import type {StackScreenProps} from '@react-navigation/stack';
+import React from 'react';
+import {View} from 'react-native';
+import FormProvider from '@components/Form/FormProvider';
+import InputWrapper from '@components/Form/InputWrapper';
+import HeaderWithBackButton from '@components/HeaderWithBackButton';
+import ScreenWrapper from '@components/ScreenWrapper';
+import TextInput from '@components/TextInput';
+import useAutoFocusInput from '@hooks/useAutoFocusInput';
+import useLocalize from '@hooks/useLocalize';
+import useThemeStyles from '@hooks/useThemeStyles';
+import {setPolicyCustomTaxName} from '@libs/actions/Policy';
+import Navigation from '@libs/Navigation/Navigation';
+import type {SettingsNavigatorParamList} from '@libs/Navigation/types';
+import AdminPolicyAccessOrNotFoundWrapper from '@pages/workspace/AdminPolicyAccessOrNotFoundWrapper';
+import PaidPolicyAccessOrNotFoundWrapper from '@pages/workspace/PaidPolicyAccessOrNotFoundWrapper';
+import type {WithPolicyAndFullscreenLoadingProps} from '@pages/workspace/withPolicyAndFullscreenLoading';
+import withPolicyAndFullscreenLoading from '@pages/workspace/withPolicyAndFullscreenLoading';
+import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
+import ROUTES from '@src/ROUTES';
+import type SCREENS from '@src/SCREENS';
+import INPUT_IDS from '@src/types/form/WorkspaceTaxCustomName';
+import type {WorkspaceTaxCustomName} from '@src/types/form/WorkspaceTaxCustomName';
+
+type WorkspaceTaxesSettingsCustomTaxNameProps = WithPolicyAndFullscreenLoadingProps & StackScreenProps;
+
+function WorkspaceTaxesSettingsCustomTaxName({
+ route: {
+ params: {policyID},
+ },
+ policy,
+}: WorkspaceTaxesSettingsCustomTaxNameProps) {
+ const styles = useThemeStyles();
+ const {translate} = useLocalize();
+ const {inputCallbackRef} = useAutoFocusInput();
+
+ const submit = ({name}: WorkspaceTaxCustomName) => {
+ setPolicyCustomTaxName(policyID, name);
+ Navigation.goBack(ROUTES.WORKSPACE_TAXES_SETTINGS.getRoute(policyID));
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+WorkspaceTaxesSettingsCustomTaxName.displayName = 'WorkspaceTaxesSettingsCustomTaxName';
+
+export default withPolicyAndFullscreenLoading(WorkspaceTaxesSettingsCustomTaxName);
diff --git a/src/pages/workspace/taxes/WorkspaceTaxesSettingsForeignCurrency.tsx b/src/pages/workspace/taxes/WorkspaceTaxesSettingsForeignCurrency.tsx
new file mode 100644
index 000000000000..4a6626a78286
--- /dev/null
+++ b/src/pages/workspace/taxes/WorkspaceTaxesSettingsForeignCurrency.tsx
@@ -0,0 +1,68 @@
+import type {StackScreenProps} from '@react-navigation/stack';
+import React from 'react';
+import {View} from 'react-native';
+import HeaderWithBackButton from '@components/HeaderWithBackButton';
+import ScreenWrapper from '@components/ScreenWrapper';
+import type {ListItem} from '@components/SelectionList/types';
+import TaxPicker from '@components/TaxPicker';
+import useLocalize from '@hooks/useLocalize';
+import useThemeStyles from '@hooks/useThemeStyles';
+import {setForeignCurrencyDefault} from '@libs/actions/Policy';
+import Navigation from '@libs/Navigation/Navigation';
+import type {SettingsNavigatorParamList} from '@libs/Navigation/types';
+import AdminPolicyAccessOrNotFoundWrapper from '@pages/workspace/AdminPolicyAccessOrNotFoundWrapper';
+import PaidPolicyAccessOrNotFoundWrapper from '@pages/workspace/PaidPolicyAccessOrNotFoundWrapper';
+import type {WithPolicyAndFullscreenLoadingProps} from '@pages/workspace/withPolicyAndFullscreenLoading';
+import withPolicyAndFullscreenLoading from '@pages/workspace/withPolicyAndFullscreenLoading';
+import ROUTES from '@src/ROUTES';
+import type SCREENS from '@src/SCREENS';
+
+type WorkspaceTaxesSettingsForeignCurrencyProps = WithPolicyAndFullscreenLoadingProps &
+ StackScreenProps;
+
+function WorkspaceTaxesSettingsForeignCurrency({
+ route: {
+ params: {policyID},
+ },
+ policy,
+}: WorkspaceTaxesSettingsForeignCurrencyProps) {
+ const {translate} = useLocalize();
+ const styles = useThemeStyles();
+
+ const submit = ({keyForList}: ListItem) => {
+ setForeignCurrencyDefault(policyID, keyForList ?? '');
+ Navigation.goBack(ROUTES.WORKSPACE_TAXES_SETTINGS.getRoute(policyID));
+ };
+
+ return (
+
+
+
+ {({insets}) => (
+ <>
+
+
+
+
+
+ >
+ )}
+
+
+
+ );
+}
+
+WorkspaceTaxesSettingsForeignCurrency.displayName = 'WorkspaceTaxesSettingsForeignCurrency';
+
+export default withPolicyAndFullscreenLoading(WorkspaceTaxesSettingsForeignCurrency);
diff --git a/src/pages/workspace/taxes/WorkspaceTaxesSettingsPage.tsx b/src/pages/workspace/taxes/WorkspaceTaxesSettingsPage.tsx
new file mode 100644
index 000000000000..1fe6abb96b4c
--- /dev/null
+++ b/src/pages/workspace/taxes/WorkspaceTaxesSettingsPage.tsx
@@ -0,0 +1,90 @@
+import type {StackScreenProps} from '@react-navigation/stack';
+import React, {useMemo} from 'react';
+import {View} from 'react-native';
+import HeaderWithBackButton from '@components/HeaderWithBackButton';
+import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription';
+import OfflineWithFeedback from '@components/OfflineWithFeedback';
+import ScreenWrapper from '@components/ScreenWrapper';
+import ScrollView from '@components/ScrollView';
+import useLocalize from '@hooks/useLocalize';
+import useThemeStyles from '@hooks/useThemeStyles';
+import Navigation from '@libs/Navigation/Navigation';
+import type {SettingsNavigatorParamList} from '@libs/Navigation/types';
+import AdminPolicyAccessOrNotFoundWrapper from '@pages/workspace/AdminPolicyAccessOrNotFoundWrapper';
+import PaidPolicyAccessOrNotFoundWrapper from '@pages/workspace/PaidPolicyAccessOrNotFoundWrapper';
+import withPolicyAndFullscreenLoading from '@pages/workspace/withPolicyAndFullscreenLoading';
+import type {WithPolicyAndFullscreenLoadingProps} from '@pages/workspace/withPolicyAndFullscreenLoading';
+import ROUTES from '@src/ROUTES';
+import type SCREENS from '@src/SCREENS';
+
+type WorkspaceTaxesSettingsPageProps = WithPolicyAndFullscreenLoadingProps & StackScreenProps;
+
+function WorkspaceTaxesSettingsPage({
+ route: {
+ params: {policyID},
+ },
+ policy,
+}: WorkspaceTaxesSettingsPageProps) {
+ const styles = useThemeStyles();
+ const {translate} = useLocalize();
+
+ const menuItems = useMemo(
+ () => [
+ {
+ title: policy?.taxRates?.name,
+ description: translate('workspace.taxes.customTaxName'),
+ action: () => Navigation.navigate(ROUTES.WORKSPACE_TAXES_SETTINGS_CUSTOM_TAX_NAME.getRoute(policyID)),
+ pendingAction: policy?.taxRates?.pendingFields?.name,
+ },
+ {
+ title: policy?.taxRates?.taxes[policy?.taxRates?.defaultExternalID]?.name,
+ description: translate('workspace.taxes.workspaceDefault'),
+ action: () => Navigation.navigate(ROUTES.WORKSPACE_TAXES_SETTINGS_WORKSPACE_CURRENCY_DEFAULT.getRoute(policyID)),
+ pendingAction: policy?.taxRates?.pendingFields?.defaultExternalID,
+ },
+ {
+ title: policy?.taxRates?.taxes[policy?.taxRates?.foreignTaxDefault]?.name,
+ description: translate('workspace.taxes.foreignDefault'),
+ action: () => Navigation.navigate(ROUTES.WORKSPACE_TAXES_SETTINGS_FOREIGN_CURRENCY_DEFAULT.getRoute(policyID)),
+ pendingAction: policy?.taxRates?.pendingFields?.foreignTaxDefault,
+ },
+ ],
+ [policy?.taxRates, policyID, translate],
+ );
+
+ return (
+
+
+
+
+
+
+ {menuItems.map((item) => (
+
+
+
+ ))}
+
+
+
+
+
+ );
+}
+
+WorkspaceTaxesSettingsPage.displayName = 'WorkspaceTaxesSettingsPage';
+
+export default withPolicyAndFullscreenLoading(WorkspaceTaxesSettingsPage);
diff --git a/src/pages/workspace/taxes/WorkspaceTaxesSettingsWorkspaceCurrency.tsx b/src/pages/workspace/taxes/WorkspaceTaxesSettingsWorkspaceCurrency.tsx
new file mode 100644
index 000000000000..68c50f3af830
--- /dev/null
+++ b/src/pages/workspace/taxes/WorkspaceTaxesSettingsWorkspaceCurrency.tsx
@@ -0,0 +1,68 @@
+import type {StackScreenProps} from '@react-navigation/stack';
+import React from 'react';
+import {View} from 'react-native';
+import HeaderWithBackButton from '@components/HeaderWithBackButton';
+import ScreenWrapper from '@components/ScreenWrapper';
+import type {ListItem} from '@components/SelectionList/types';
+import TaxPicker from '@components/TaxPicker';
+import useLocalize from '@hooks/useLocalize';
+import useThemeStyles from '@hooks/useThemeStyles';
+import {setWorkspaceCurrencyDefault} from '@libs/actions/Policy';
+import Navigation from '@libs/Navigation/Navigation';
+import type {SettingsNavigatorParamList} from '@libs/Navigation/types';
+import AdminPolicyAccessOrNotFoundWrapper from '@pages/workspace/AdminPolicyAccessOrNotFoundWrapper';
+import PaidPolicyAccessOrNotFoundWrapper from '@pages/workspace/PaidPolicyAccessOrNotFoundWrapper';
+import type {WithPolicyAndFullscreenLoadingProps} from '@pages/workspace/withPolicyAndFullscreenLoading';
+import withPolicyAndFullscreenLoading from '@pages/workspace/withPolicyAndFullscreenLoading';
+import ROUTES from '@src/ROUTES';
+import type SCREENS from '@src/SCREENS';
+
+type WorkspaceTaxesSettingsWorkspaceCurrencyProps = WithPolicyAndFullscreenLoadingProps &
+ StackScreenProps;
+
+function WorkspaceTaxesSettingsWorkspaceCurrency({
+ route: {
+ params: {policyID},
+ },
+ policy,
+}: WorkspaceTaxesSettingsWorkspaceCurrencyProps) {
+ const {translate} = useLocalize();
+ const styles = useThemeStyles();
+
+ const submit = ({keyForList}: ListItem) => {
+ setWorkspaceCurrencyDefault(policyID, keyForList ?? '');
+ Navigation.goBack(ROUTES.WORKSPACE_TAXES_SETTINGS.getRoute(policyID));
+ };
+
+ return (
+
+
+
+ {({insets}) => (
+ <>
+
+
+
+
+
+ >
+ )}
+
+
+
+ );
+}
+
+WorkspaceTaxesSettingsWorkspaceCurrency.displayName = 'WorkspaceTaxesSettingsWorkspaceCurrency';
+
+export default withPolicyAndFullscreenLoading(WorkspaceTaxesSettingsWorkspaceCurrency);
diff --git a/src/styles/index.ts b/src/styles/index.ts
index df89cd823fa4..8a91291a0c71 100644
--- a/src/styles/index.ts
+++ b/src/styles/index.ts
@@ -3449,6 +3449,12 @@ const styles = (theme: ThemeColors) =>
zIndex: 1000,
},
+ invisibleImage: {
+ opacity: 0,
+ width: 200,
+ height: 200,
+ },
+
reportDropOverlay: {
backgroundColor: theme.dropUIBG,
zIndex: 2,
diff --git a/src/types/form/WorkspaceNewTaxForm.ts b/src/types/form/WorkspaceNewTaxForm.ts
new file mode 100644
index 000000000000..7ba9b8931bf4
--- /dev/null
+++ b/src/types/form/WorkspaceNewTaxForm.ts
@@ -0,0 +1,20 @@
+import type {ValueOf} from 'type-fest';
+import type Form from './Form';
+
+const INPUT_IDS = {
+ NAME: 'name',
+ VALUE: 'value',
+} as const;
+
+type InputID = ValueOf;
+
+type WorkspaceNewTaxForm = Form<
+ InputID,
+ {
+ [INPUT_IDS.NAME]: string;
+ [INPUT_IDS.VALUE]: string;
+ }
+>;
+
+export type {WorkspaceNewTaxForm};
+export default INPUT_IDS;
diff --git a/src/types/form/WorkspaceTaxCustomName.ts b/src/types/form/WorkspaceTaxCustomName.ts
new file mode 100644
index 000000000000..db6522732e7f
--- /dev/null
+++ b/src/types/form/WorkspaceTaxCustomName.ts
@@ -0,0 +1,18 @@
+import type {ValueOf} from 'type-fest';
+import type Form from './Form';
+
+const INPUT_IDS = {
+ NAME: 'name',
+} as const;
+
+type InputID = ValueOf;
+
+type WorkspaceTaxCustomName = Form<
+ InputID,
+ {
+ [INPUT_IDS.NAME]: string;
+ }
+>;
+
+export type {WorkspaceTaxCustomName};
+export default INPUT_IDS;
diff --git a/src/types/form/index.ts b/src/types/form/index.ts
index d88de0639663..ddd460e46eac 100644
--- a/src/types/form/index.ts
+++ b/src/types/form/index.ts
@@ -39,6 +39,8 @@ export type {WorkspaceSettingsForm} from './WorkspaceSettingsForm';
export type {ReportPhysicalCardForm} from './ReportPhysicalCardForm';
export type {WorkspaceDescriptionForm} from './WorkspaceDescriptionForm';
export type {PolicyTagNameForm} from './PolicyTagNameForm';
+export type {WorkspaceNewTaxForm} from './WorkspaceNewTaxForm';
export type {WorkspaceTagCreateForm} from './WorkspaceTagCreateForm';
+export type {WorkspaceTaxCustomName} from './WorkspaceTaxCustomName';
export type {PolicyCreateDistanceRateForm} from './PolicyCreateDistanceRateForm';
export type {default as Form} from './Form';
diff --git a/src/types/onyx/Policy.ts b/src/types/onyx/Policy.ts
index 688300933a5e..84ac101a7d7a 100644
--- a/src/types/onyx/Policy.ts
+++ b/src/types/onyx/Policy.ts
@@ -32,26 +32,29 @@ type DisabledFields = {
reimbursable?: boolean;
};
-type TaxRate = {
+type TaxRate = OnyxCommon.OnyxValueWithOfflineFeedback<{
/** Name of the a tax rate. */
name: string;
/** The value of the tax rate as percentage. */
value: string;
- /** The code associated with the tax rate. */
- code: string;
+ /** The code associated with the tax rate. If a tax is created in old dot, code field is undefined */
+ code?: string;
/** This contains the tax name and tax value as one name */
- modifiedName: string;
+ modifiedName?: string;
/** Indicates if the tax rate is disabled. */
isDisabled?: boolean;
-};
+
+ /** An error message to display to the user */
+ errors?: OnyxCommon.Errors;
+}>;
type TaxRates = Record;
-type TaxRatesWithDefault = {
+type TaxRatesWithDefault = OnyxCommon.OnyxValueWithOfflineFeedback<{
/** Name of the tax */
name: string;
@@ -66,7 +69,13 @@ type TaxRatesWithDefault = {
/** List of tax names and values */
taxes: TaxRates;
-};
+
+ /** An error message to display to the user */
+ errors?: OnyxCommon.Errors;
+
+ /** Error objects keyed by field name containing errors keyed by microtime */
+ errorFields?: OnyxCommon.ErrorFields;
+}>;
type ConnectionLastSync = {
successfulDate?: string;
diff --git a/src/types/onyx/PolicyCategory.ts b/src/types/onyx/PolicyCategory.ts
index 0fd498632dbc..b42bceec0468 100644
--- a/src/types/onyx/PolicyCategory.ts
+++ b/src/types/onyx/PolicyCategory.ts
@@ -15,7 +15,7 @@ type PolicyCategory = OnyxCommon.OnyxValueWithOfflineFeedback<{
/** "General Ledger code" that corresponds to this category in an accounting system. Similar to an ID. */
// eslint-disable-next-line @typescript-eslint/naming-convention
- 'GL Code': string;
+ 'GL Code'?: string;
/** An ID for this category from an external accounting system */
externalID: string;
diff --git a/src/types/onyx/PolicyTag.ts b/src/types/onyx/PolicyTag.ts
index 70a0884c30bd..8066b85d1e44 100644
--- a/src/types/onyx/PolicyTag.ts
+++ b/src/types/onyx/PolicyTag.ts
@@ -9,7 +9,7 @@ type PolicyTag = {
/** "General Ledger code" that corresponds to this tag in an accounting system. Similar to an ID. */
// eslint-disable-next-line @typescript-eslint/naming-convention
- 'GL Code': string;
+ 'GL Code'?: string;
/** A list of errors keyed by microtime */
errors?: OnyxCommon.Errors | null;
diff --git a/src/types/utils/callOrReturn.ts b/src/types/utils/callOrReturn.ts
index d51226544962..6accb08f5a2d 100644
--- a/src/types/utils/callOrReturn.ts
+++ b/src/types/utils/callOrReturn.ts
@@ -1,6 +1,8 @@
-function callOrReturn(value: TValue | (() => TValue)): TValue {
+type Func = (...args: T) => R;
+
+function callOrReturn(value: TReturn | Func, ...args: TArgs): TReturn {
if (typeof value === 'function') {
- return (value as () => TValue)();
+ return (value as Func)(...args);
}
return value;
diff --git a/tests/actions/PolicyTest.js b/tests/actions/PolicyTest.ts
similarity index 59%
rename from tests/actions/PolicyTest.js
rename to tests/actions/PolicyTest.ts
index 5a994aaf600e..e59fec068d65 100644
--- a/tests/actions/PolicyTest.js
+++ b/tests/actions/PolicyTest.ts
@@ -1,9 +1,10 @@
-import _ from 'lodash';
import Onyx from 'react-native-onyx';
+import type {OnyxCollection} from 'react-native-onyx';
import CONST from '@src/CONST';
-import OnyxUpdateManager from '../../src/libs/actions/OnyxUpdateManager';
-import * as Policy from '../../src/libs/actions/Policy';
-import ONYXKEYS from '../../src/ONYXKEYS';
+import OnyxUpdateManager from '@src/libs/actions/OnyxUpdateManager';
+import * as Policy from '@src/libs/actions/Policy';
+import ONYXKEYS from '@src/ONYXKEYS';
+import type {PolicyMembers, Policy as PolicyType, Report, ReportAction, ReportActions} from '@src/types/onyx';
import * as TestHelper from '../utils/TestHelper';
import waitForBatchedUpdates from '../utils/waitForBatchedUpdates';
@@ -20,12 +21,14 @@ describe('actions/Policy', () => {
});
beforeEach(() => {
+ // @ts-expect-error TODO: Remove this once TestHelper (https://github.com/Expensify/App/issues/25318) is migrated to TypeScript.
global.fetch = TestHelper.getGlobalFetchMock();
return Onyx.clear().then(waitForBatchedUpdates);
});
describe('createWorkspace', () => {
it('creates a new workspace', async () => {
+ // @ts-expect-error TODO: Remove this once TestHelper (https://github.com/Expensify/App/issues/25318) is migrated to TypeScript.
fetch.pause();
Onyx.set(ONYXKEYS.SESSION, {email: ESH_EMAIL, accountID: ESH_ACCOUNT_ID});
await waitForBatchedUpdates();
@@ -38,7 +41,7 @@ describe('actions/Policy', () => {
Policy.createWorkspace(ESH_EMAIL, true, WORKSPACE_NAME, policyID);
await waitForBatchedUpdates();
- let policy = await new Promise((resolve) => {
+ let policy: OnyxCollection = await new Promise((resolve) => {
const connectionID = Onyx.connect({
key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
waitForCollectionCallback: true,
@@ -50,15 +53,15 @@ describe('actions/Policy', () => {
});
// check if policy was created with correct values
- expect(policy.id).toBe(policyID);
- expect(policy.name).toBe(WORKSPACE_NAME);
- expect(policy.type).toBe(CONST.POLICY.TYPE.FREE);
- expect(policy.role).toBe(CONST.POLICY.ROLE.ADMIN);
- expect(policy.owner).toBe(ESH_EMAIL);
- expect(policy.isPolicyExpenseChatEnabled).toBe(true);
- expect(policy.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD);
-
- const policyMembers = await new Promise((resolve) => {
+ expect(policy?.id).toBe(policyID);
+ expect(policy?.name).toBe(WORKSPACE_NAME);
+ expect(policy?.type).toBe(CONST.POLICY.TYPE.FREE);
+ expect(policy?.role).toBe(CONST.POLICY.ROLE.ADMIN);
+ expect(policy?.owner).toBe(ESH_EMAIL);
+ expect(policy?.isPolicyExpenseChatEnabled).toBe(true);
+ expect(policy?.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD);
+
+ const policyMembers: OnyxCollection = await new Promise((resolve) => {
const connectionID = Onyx.connect({
key: `${ONYXKEYS.COLLECTION.POLICY_MEMBERS}${policyID}`,
waitForCollectionCallback: true,
@@ -70,9 +73,9 @@ describe('actions/Policy', () => {
});
// check if the user was added as an admin to the policy
- expect(policyMembers[ESH_ACCOUNT_ID].role).toBe(CONST.POLICY.ROLE.ADMIN);
+ expect(policyMembers?.[ESH_ACCOUNT_ID]?.role).toBe(CONST.POLICY.ROLE.ADMIN);
- let allReports = await new Promise((resolve) => {
+ let allReports: OnyxCollection = await new Promise((resolve) => {
const connectionID = Onyx.connect({
key: ONYXKEYS.COLLECTION.REPORT,
waitForCollectionCallback: true,
@@ -84,12 +87,12 @@ describe('actions/Policy', () => {
});
// Three reports should be created: #announce, #admins and expense report
- const workspaceReports = _.filter(allReports, (report) => report.policyID === policyID);
- expect(_.size(workspaceReports)).toBe(3);
- _.forEach(workspaceReports, (report) => {
- expect(report.pendingFields.addWorkspaceRoom).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD);
- expect(report.participantAccountIDs).toEqual([ESH_ACCOUNT_ID]);
- switch (report.chatType) {
+ const workspaceReports = Object.values(allReports ?? {}).filter((report) => report?.policyID === policyID);
+ expect(workspaceReports.length).toBe(3);
+ workspaceReports.forEach((report) => {
+ expect(report?.pendingFields?.addWorkspaceRoom).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD);
+ expect(report?.participantAccountIDs).toEqual([ESH_ACCOUNT_ID]);
+ switch (report?.chatType) {
case CONST.REPORT.CHAT_TYPE.POLICY_ADMINS: {
adminReportID = report.reportID;
break;
@@ -107,7 +110,7 @@ describe('actions/Policy', () => {
}
});
- let reportActions = await new Promise((resolve) => {
+ let reportActions: OnyxCollection = await new Promise((resolve) => {
const connectionID = Onyx.connect({
key: ONYXKEYS.COLLECTION.REPORT_ACTIONS,
waitForCollectionCallback: true,
@@ -119,20 +122,21 @@ describe('actions/Policy', () => {
});
// Each of the three reports should have a a `CREATED` action.
- let adminReportActions = _.values(reportActions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${adminReportID}`]);
- let announceReportActions = _.values(reportActions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${announceReportID}`]);
- let expenseReportActions = _.values(reportActions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReportID}`]);
- let workspaceReportActions = _.concat(adminReportActions, announceReportActions, expenseReportActions);
- _.forEach([adminReportActions, announceReportActions, expenseReportActions], (actions) => {
- expect(_.size(actions)).toBe(1);
+ let adminReportActions: ReportAction[] = Object.values(reportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${adminReportID}`] ?? {});
+ let announceReportActions: ReportAction[] = Object.values(reportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${announceReportID}`] ?? {});
+ let expenseReportActions: ReportAction[] = Object.values(reportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReportID}`] ?? {});
+ let workspaceReportActions: ReportAction[] = adminReportActions.concat(announceReportActions, expenseReportActions);
+ [adminReportActions, announceReportActions, expenseReportActions].forEach((actions) => {
+ expect(actions.length).toBe(1);
});
- _.forEach([...adminReportActions, ...announceReportActions, ...expenseReportActions], (reportAction) => {
+ [...adminReportActions, ...announceReportActions, ...expenseReportActions].forEach((reportAction) => {
expect(reportAction.actionName).toBe(CONST.REPORT.ACTIONS.TYPE.CREATED);
expect(reportAction.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD);
expect(reportAction.actorAccountID).toBe(ESH_ACCOUNT_ID);
});
// Check for success data
+ // @ts-expect-error TODO: Remove this once TestHelper (https://github.com/Expensify/App/issues/25318) is migrated to TypeScript.
fetch.resume();
await waitForBatchedUpdates();
@@ -148,7 +152,7 @@ describe('actions/Policy', () => {
});
// Check if the policy pending action was cleared
- expect(policy.pendingAction).toBeFalsy();
+ expect(policy?.pendingAction).toBeFalsy();
allReports = await new Promise((resolve) => {
const connectionID = Onyx.connect({
@@ -162,9 +166,9 @@ describe('actions/Policy', () => {
});
// Check if the report pending action and fields were cleared
- _.forEach(allReports, (report) => {
- expect(report.pendingAction).toBeFalsy();
- expect(report.pendingFields.addWorkspaceRoom).toBeFalsy();
+ Object.values(allReports ?? {}).forEach((report) => {
+ expect(report?.pendingAction).toBeFalsy();
+ expect(report?.pendingFields?.addWorkspaceRoom).toBeFalsy();
});
reportActions = await new Promise((resolve) => {
@@ -179,11 +183,11 @@ describe('actions/Policy', () => {
});
// Check if the report action pending action was cleared
- adminReportActions = _.values(reportActions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${adminReportID}`]);
- announceReportActions = _.values(reportActions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${announceReportID}`]);
- expenseReportActions = _.values(reportActions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReportID}`]);
- workspaceReportActions = _.concat(adminReportActions, announceReportActions, expenseReportActions);
- _.forEach(workspaceReportActions, (reportAction) => {
+ adminReportActions = Object.values(reportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${adminReportID}`] ?? {});
+ announceReportActions = Object.values(reportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${announceReportID}`] ?? {});
+ expenseReportActions = Object.values(reportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReportID}`] ?? {});
+ workspaceReportActions = adminReportActions.concat(announceReportActions, expenseReportActions);
+ workspaceReportActions.forEach((reportAction) => {
expect(reportAction.pendingAction).toBeFalsy();
});
});
diff --git a/tests/perf-test/OptionsSelector.perf-test.js b/tests/perf-test/OptionsSelector.perf-test.tsx
similarity index 67%
rename from tests/perf-test/OptionsSelector.perf-test.js
rename to tests/perf-test/OptionsSelector.perf-test.tsx
index 6104ded05c6a..835e2a15673c 100644
--- a/tests/perf-test/OptionsSelector.perf-test.js
+++ b/tests/perf-test/OptionsSelector.perf-test.tsx
@@ -1,12 +1,15 @@
import {fireEvent} from '@testing-library/react-native';
+import type {RenderResult} from '@testing-library/react-native';
import React from 'react';
+import type {ComponentType} from 'react';
import {measurePerformance} from 'reassure';
-import _ from 'underscore';
+import type {WithLocalizeProps} from '@components/withLocalize';
+import type {WithNavigationFocusProps} from '@components/withNavigationFocus';
import OptionsSelector from '@src/components/OptionsSelector';
import variables from '@src/styles/variables';
-jest.mock('../../src/components/withLocalize', () => (Component) => {
- function WrappedComponent(props) {
+jest.mock('@src/components/withLocalize', () => (Component: ComponentType) => {
+ function WrappedComponent(props: WithLocalizeProps) {
return (
(Component) => {
return WrappedComponent;
});
-jest.mock('../../src/components/withNavigationFocus', () => (Component) => {
- function WithNavigationFocus(props) {
+jest.mock('@src/components/withNavigationFocus', () => (Component: ComponentType) => {
+ function WithNavigationFocus(props: WithNavigationFocusProps) {
return (
(Component) => {
return WithNavigationFocus;
});
-const generateSections = (sectionConfigs) =>
- _.map(sectionConfigs, ({numItems, indexOffset, shouldShow = true}) => ({
- data: Array.from({length: numItems}, (_v, i) => ({
+type GenerateSectionsProps = Array<{numberOfItems: number; indexOffset: number; shouldShow?: boolean}>;
+
+const generateSections = (sections: GenerateSectionsProps) =>
+ sections.map(({numberOfItems, indexOffset, shouldShow = true}) => ({
+ data: Array.from({length: numberOfItems}, (v, i) => ({
text: `Item ${i + indexOffset}`,
keyForList: `item-${i + indexOffset}`,
})),
@@ -45,15 +50,15 @@ const generateSections = (sectionConfigs) =>
shouldShow,
}));
-const singleSectionSConfig = [{numItems: 1000, indexOffset: 0}];
+const singleSectionsConfig = [{numberOfItems: 1000, indexOffset: 0}];
const mutlipleSectionsConfig = [
- {numItems: 1000, indexOffset: 0},
- {numItems: 100, indexOffset: 70},
+ {numberOfItems: 1000, indexOffset: 0},
+ {numberOfItems: 100, indexOffset: 70},
];
-
+// @ts-expect-error TODO: Remove this once OptionsSelector is migrated to TypeScript.
function OptionsSelectorWrapper(args) {
- const sections = generateSections(singleSectionSConfig);
+ const sections = generateSections(singleSectionsConfig);
return (
{
- const scenario = (screen) => {
+ const scenario = ((screen: RenderResult) => {
const textInput = screen.getByTestId('options-selector-input');
fireEvent.changeText(textInput, 'test');
fireEvent.changeText(textInput, 'test2');
fireEvent.changeText(textInput, 'test3');
- };
+ }) as Awaited<(screen: RenderResult) => Promise>;
measurePerformance(, {scenario});
});
@@ -85,11 +90,11 @@ test('[OptionsSelector] should render multiple sections', () => {
});
test('[OptionsSelector] should press a list items', () => {
- const scenario = (screen) => {
+ const scenario = ((screen: RenderResult) => {
fireEvent.press(screen.getByText('Item 1'));
fireEvent.press(screen.getByText('Item 5'));
fireEvent.press(screen.getByText('Item 10'));
- };
+ }) as Awaited<(screen: RenderResult) => Promise>;
measurePerformance(, {scenario});
});
@@ -97,10 +102,10 @@ test('[OptionsSelector] should press a list items', () => {
test('[OptionsSelector] should scroll and press few items', () => {
const sections = generateSections(mutlipleSectionsConfig);
- const generateEventData = (numOptions, optionRowHeight) => ({
+ const generateEventData = (numberOfOptions: number, optionRowHeight: number) => ({
nativeEvent: {
contentOffset: {
- y: optionRowHeight * numOptions,
+ y: optionRowHeight * numberOfOptions,
},
contentSize: {
height: optionRowHeight * 10,
@@ -115,7 +120,7 @@ test('[OptionsSelector] should scroll and press few items', () => {
const eventData = generateEventData(100, variables.optionRowHeight);
const eventData2 = generateEventData(200, variables.optionRowHeight);
- const scenario = async (screen) => {
+ const scenario = async (screen: RenderResult) => {
fireEvent.press(screen.getByText('Item 10'));
fireEvent.scroll(screen.getByTestId('options-list'), eventData);
fireEvent.press(await screen.findByText('Item 100'));
diff --git a/tests/perf-test/SearchPage.perf-test.js b/tests/perf-test/SearchPage.perf-test.tsx
similarity index 50%
rename from tests/perf-test/SearchPage.perf-test.js
rename to tests/perf-test/SearchPage.perf-test.tsx
index be6b6a5d78f9..3f3395092b26 100644
--- a/tests/perf-test/SearchPage.perf-test.js
+++ b/tests/perf-test/SearchPage.perf-test.tsx
@@ -1,14 +1,22 @@
+import type * as NativeNavigation from '@react-navigation/native';
+import type {StackScreenProps} from '@react-navigation/stack';
import {fireEvent, screen, waitFor} from '@testing-library/react-native';
+import type {TextMatch} from '@testing-library/react-native/build/matches';
import React from 'react';
+import type {ComponentType} from 'react';
import Onyx from 'react-native-onyx';
+import type {OnyxCollection, OnyxEntry} from 'react-native-onyx';
import {measurePerformance} from 'reassure';
-import _ from 'underscore';
import {LocaleContextProvider} from '@components/LocaleContextProvider';
+import type {WithNavigationFocusProps} from '@components/withNavigationFocus';
+import type {RootStackParamList} from '@libs/Navigation/types';
import SearchPage from '@pages/SearchPage';
-import ComposeProviders from '../../src/components/ComposeProviders';
-import OnyxProvider from '../../src/components/OnyxProvider';
-import CONST from '../../src/CONST';
-import ONYXKEYS from '../../src/ONYXKEYS';
+import ComposeProviders from '@src/components/ComposeProviders';
+import OnyxProvider from '@src/components/OnyxProvider';
+import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
+import type SCREENS from '@src/SCREENS';
+import type {Beta, PersonalDetails, Report} from '@src/types/onyx';
import createCollection from '../utils/collections/createCollection';
import createPersonalDetails from '../utils/collections/personalDetails';
import createRandomReport from '../utils/collections/reports';
@@ -18,22 +26,22 @@ import waitForBatchedUpdates from '../utils/waitForBatchedUpdates';
import wrapOnyxWithWaitForBatchedUpdates from '../utils/wrapOnyxWithWaitForBatchedUpdates';
jest.mock('lodash/debounce', () =>
- jest.fn((fn) => {
+ jest.fn((fn: Record>) => {
// eslint-disable-next-line no-param-reassign
fn.cancel = jest.fn();
return fn;
}),
);
-jest.mock('../../src/libs/Log');
+jest.mock('@src/libs/Log');
-jest.mock('../../src/libs/API', () => ({
+jest.mock('@src/libs/API', () => ({
write: jest.fn(),
makeRequestWithSideEffects: jest.fn(),
read: jest.fn(),
}));
-jest.mock('../../src/libs/Navigation/Navigation');
+jest.mock('@src/libs/Navigation/Navigation');
const mockedNavigate = jest.fn();
jest.mock('@react-navigation/native', () => {
@@ -50,11 +58,11 @@ jest.mock('@react-navigation/native', () => {
addListener: () => jest.fn(),
}),
createNavigationContainerRef: jest.fn(),
- };
+ } as typeof NativeNavigation;
});
-jest.mock('../../src/components/withNavigationFocus', () => (Component) => {
- function WithNavigationFocus(props) {
+jest.mock('@src/components/withNavigationFocus', () => (Component: ComponentType) => {
+ function WithNavigationFocus(props: WithNavigationFocusProps) {
return (
(Component) => {
});
const getMockedReports = (length = 100) =>
- createCollection(
+ createCollection(
(item) => `${ONYXKEYS.COLLECTION.REPORT}${item.reportID}`,
(index) => createRandomReport(index),
length,
);
const getMockedPersonalDetails = (length = 100) =>
- createCollection(
+ createCollection(
(item) => item.accountID,
(index) => createPersonalDetails(index),
length,
);
const mockedReports = getMockedReports(600);
-const mockedBetas = _.values(CONST.BETAS);
+const mockedBetas = Object.values(CONST.BETAS);
const mockedPersonalDetails = getMockedPersonalDetails(100);
beforeAll(() =>
Onyx.init({
keys: ONYXKEYS,
safeEvictionKeys: [ONYXKEYS.COLLECTION.REPORT],
- registerStorageEventListener: () => {},
}),
);
// Initialize the network key for OfflineWithFeedback
beforeEach(() => {
+ // @ts-expect-error TODO: Remove this once TestHelper (https://github.com/Expensify/App/issues/25318) is migrated to TypeScript.
global.fetch = TestHelper.getGlobalFetchMock();
wrapOnyxWithWaitForBatchedUpdates(Onyx);
Onyx.merge(ONYXKEYS.NETWORK, {isOffline: false});
@@ -108,7 +116,13 @@ afterEach(() => {
PusherHelper.teardown();
});
-function SearchPageWrapper(args) {
+type SearchPageProps = StackScreenProps & {
+ betas: OnyxEntry;
+ reports: OnyxCollection;
+ isSearchingForReports: OnyxEntry;
+};
+
+function SearchPageWrapper(args: SearchPageProps) {
return (
{
+ // @ts-expect-error TODO: Remove this once TestHelper (https://github.com/Expensify/App/issues/25318) is migrated to TypeScript.
const {addListener} = TestHelper.createAddListenerMock();
const scenario = async () => {
@@ -134,81 +149,93 @@ test('[Search Page] should interact when text input changes', async () => {
const navigation = {addListener};
- return waitForBatchedUpdates()
- .then(() =>
- Onyx.multiSet({
- ...mockedReports,
- [ONYXKEYS.PERSONAL_DETAILS_LIST]: mockedPersonalDetails,
- [ONYXKEYS.BETAS]: mockedBetas,
- [ONYXKEYS.IS_SEARCHING_FOR_REPORTS]: true,
- }),
- )
- .then(() => measurePerformance(, {scenario}));
+ return (
+ waitForBatchedUpdates()
+ .then(() =>
+ Onyx.multiSet({
+ ...mockedReports,
+ [ONYXKEYS.PERSONAL_DETAILS_LIST]: mockedPersonalDetails,
+ [ONYXKEYS.BETAS]: mockedBetas,
+ [ONYXKEYS.IS_SEARCHING_FOR_REPORTS]: true,
+ }),
+ )
+ // @ts-expect-error TODO: Remove this once TestHelper (https://github.com/Expensify/App/issues/25318) is migrated to TypeScript.
+ .then(() => measurePerformance(, {scenario}))
+ );
});
test('[Search Page] should render selection list', async () => {
+ // @ts-expect-error TODO: Remove this once TestHelper (https://github.com/Expensify/App/issues/25318) is migrated to TypeScript.
const {triggerTransitionEnd, addListener} = TestHelper.createAddListenerMock();
const smallMockedPersonalDetails = getMockedPersonalDetails(5);
const scenario = async () => {
await screen.findByTestId('SearchPage');
- await waitFor(triggerTransitionEnd);
+ await waitFor(triggerTransitionEnd as Awaited<() => Promise>);
await screen.findByTestId('selection-list');
- await screen.findByText(smallMockedPersonalDetails['1'].login);
- await screen.findByText(smallMockedPersonalDetails['2'].login);
+ await screen.findByText(smallMockedPersonalDetails['1'].login as TextMatch);
+ await screen.findByText(smallMockedPersonalDetails['2'].login as TextMatch);
};
const navigation = {addListener};
- return waitForBatchedUpdates()
- .then(() =>
- Onyx.multiSet({
- ...mockedReports,
- [ONYXKEYS.PERSONAL_DETAILS_LIST]: smallMockedPersonalDetails,
- [ONYXKEYS.BETAS]: mockedBetas,
- [ONYXKEYS.IS_SEARCHING_FOR_REPORTS]: true,
- }),
- )
- .then(() => measurePerformance(, {scenario}));
+ return (
+ waitForBatchedUpdates()
+ .then(() =>
+ Onyx.multiSet({
+ ...mockedReports,
+ [ONYXKEYS.PERSONAL_DETAILS_LIST]: smallMockedPersonalDetails,
+ [ONYXKEYS.BETAS]: mockedBetas,
+ [ONYXKEYS.IS_SEARCHING_FOR_REPORTS]: true,
+ }),
+ )
+ // @ts-expect-error TODO: Remove this once TestHelper (https://github.com/Expensify/App/issues/25318) is migrated to TypeScript.
+ .then(() => measurePerformance(, {scenario}))
+ );
});
test('[Search Page] should search in selection list', async () => {
+ // @ts-expect-error TODO: Remove this once TestHelper (https://github.com/Expensify/App/issues/25318) is migrated to TypeScript.
const {triggerTransitionEnd, addListener} = TestHelper.createAddListenerMock();
const scenario = async () => {
await screen.findByTestId('SearchPage');
- await waitFor(triggerTransitionEnd);
+ await waitFor(triggerTransitionEnd as Awaited<() => Promise>);
const input = screen.getByTestId('selection-list-text-input');
const searchValue = mockedPersonalDetails['88'].login;
fireEvent.changeText(input, searchValue);
- await screen.findByText(searchValue);
+ await screen.findByText(searchValue as TextMatch);
};
const navigation = {addListener};
- return waitForBatchedUpdates()
- .then(() =>
- Onyx.multiSet({
- ...mockedReports,
- [ONYXKEYS.PERSONAL_DETAILS_LIST]: mockedPersonalDetails,
- [ONYXKEYS.BETAS]: mockedBetas,
- [ONYXKEYS.IS_SEARCHING_FOR_REPORTS]: true,
- }),
- )
- .then(() => measurePerformance(, {scenario}));
+ return (
+ waitForBatchedUpdates()
+ .then(() =>
+ Onyx.multiSet({
+ ...mockedReports,
+ [ONYXKEYS.PERSONAL_DETAILS_LIST]: mockedPersonalDetails,
+ [ONYXKEYS.BETAS]: mockedBetas,
+ [ONYXKEYS.IS_SEARCHING_FOR_REPORTS]: true,
+ }),
+ )
+ // @ts-expect-error TODO: Remove this once TestHelper (https://github.com/Expensify/App/issues/25318) is migrated to TypeScript.
+ .then(() => measurePerformance(, {scenario}))
+ );
});
test('[Search Page] should click on list item', async () => {
+ // @ts-expect-error TODO: Remove this once TestHelper (https://github.com/Expensify/App/issues/25318) is migrated to TypeScript.
const {triggerTransitionEnd, addListener} = TestHelper.createAddListenerMock();
const scenario = async () => {
await screen.findByTestId('SearchPage');
const input = screen.getByTestId('selection-list-text-input');
- await waitFor(triggerTransitionEnd);
+ await waitFor(triggerTransitionEnd as Awaited<() => Promise>);
- const searchValue = mockedPersonalDetails['4'].login;
+ const searchValue = mockedPersonalDetails['4'].login as TextMatch;
fireEvent.changeText(input, searchValue);
const optionButton = await screen.findByText(searchValue);
@@ -216,14 +243,17 @@ test('[Search Page] should click on list item', async () => {
};
const navigation = {addListener};
- return waitForBatchedUpdates()
- .then(() =>
- Onyx.multiSet({
- ...mockedReports,
- [ONYXKEYS.PERSONAL_DETAILS_LIST]: mockedPersonalDetails,
- [ONYXKEYS.BETAS]: mockedBetas,
- [ONYXKEYS.IS_SEARCHING_FOR_REPORTS]: true,
- }),
- )
- .then(() => measurePerformance(, {scenario}));
+ return (
+ waitForBatchedUpdates()
+ .then(() =>
+ Onyx.multiSet({
+ ...mockedReports,
+ [ONYXKEYS.PERSONAL_DETAILS_LIST]: mockedPersonalDetails,
+ [ONYXKEYS.BETAS]: mockedBetas,
+ [ONYXKEYS.IS_SEARCHING_FOR_REPORTS]: true,
+ }),
+ )
+ // @ts-expect-error TODO: Remove this once TestHelper (https://github.com/Expensify/App/issues/25318) is migrated to TypeScript.
+ .then(() => measurePerformance(, {scenario}))
+ );
});
diff --git a/tests/unit/PhoneNumberTest.js b/tests/unit/PhoneNumberTest.ts
similarity index 100%
rename from tests/unit/PhoneNumberTest.js
rename to tests/unit/PhoneNumberTest.ts
diff --git a/tests/unit/UserUtilsTest.js b/tests/unit/UserUtilsTest.ts
similarity index 86%
rename from tests/unit/UserUtilsTest.js
rename to tests/unit/UserUtilsTest.ts
index f0f20fc6d4cb..f91f2a499e79 100644
--- a/tests/unit/UserUtilsTest.js
+++ b/tests/unit/UserUtilsTest.ts
@@ -1,4 +1,4 @@
-import * as UserUtils from '../../src/libs/UserUtils';
+import * as UserUtils from '@src/libs/UserUtils';
describe('UserUtils', () => {
it('should return the default avatar from the avatar url', () => {
diff --git a/tests/unit/ViolationUtilsTest.js b/tests/unit/ViolationUtilsTest.ts
similarity index 93%
rename from tests/unit/ViolationUtilsTest.js
rename to tests/unit/ViolationUtilsTest.ts
index 15a3a4f7de07..354a90802077 100644
--- a/tests/unit/ViolationUtilsTest.js
+++ b/tests/unit/ViolationUtilsTest.ts
@@ -2,6 +2,7 @@ import {beforeEach} from '@jest/globals';
import Onyx from 'react-native-onyx';
import ViolationsUtils from '@libs/Violations/ViolationsUtils';
import ONYXKEYS from '@src/ONYXKEYS';
+import type {PolicyCategories, PolicyTagList, Transaction, TransactionViolation} from '@src/types/onyx';
const categoryOutOfPolicyViolation = {
name: 'categoryOutOfPolicy',
@@ -24,15 +25,15 @@ const missingTagViolation = {
};
describe('getViolationsOnyxData', () => {
- let transaction;
- let transactionViolations;
- let policyRequiresTags;
- let policyTags;
- let policyRequiresCategories;
- let policyCategories;
+ let transaction: Transaction;
+ let transactionViolations: TransactionViolation[];
+ let policyRequiresTags: boolean;
+ let policyTags: PolicyTagList;
+ let policyRequiresCategories: boolean;
+ let policyCategories: PolicyCategories;
beforeEach(() => {
- transaction = {transactionID: '123'};
+ transaction = {transactionID: '123', reportID: '1234', amount: 100, comment: {}, created: '2023-07-24 13:46:20', merchant: 'United Airlines', currency: 'USD'};
transactionViolations = [];
policyRequiresTags = false;
policyTags = {};
@@ -62,12 +63,12 @@ describe('getViolationsOnyxData', () => {
describe('policyRequiresCategories', () => {
beforeEach(() => {
policyRequiresCategories = true;
- policyCategories = {Food: {enabled: true}};
+ policyCategories = {Food: {name: 'Food', unencodedName: '', enabled: true, areCommentsRequired: false, externalID: '1234', origin: '12345'}};
transaction.category = 'Food';
});
it('should add missingCategory violation if no category is included', () => {
- transaction.category = null;
+ transaction.category = undefined;
const result = ViolationsUtils.getViolationsOnyxData(transaction, transactionViolations, policyRequiresTags, policyTags, policyRequiresCategories, policyCategories);
expect(result.value).toEqual(expect.arrayContaining([missingCategoryViolation, ...transactionViolations]));
});
@@ -132,6 +133,7 @@ describe('getViolationsOnyxData', () => {
Lunch: {name: 'Lunch', enabled: true},
Dinner: {name: 'Dinner', enabled: true},
},
+ orderWeight: 1,
},
};
transaction.tag = 'Lunch';
@@ -209,6 +211,7 @@ describe('getViolationsOnyxData', () => {
},
},
required: true,
+ orderWeight: 1,
},
Region: {
name: 'Region',
@@ -218,6 +221,8 @@ describe('getViolationsOnyxData', () => {
enabled: true,
},
},
+ required: true,
+ orderWeight: 2,
},
Project: {
name: 'Project',
@@ -228,6 +233,7 @@ describe('getViolationsOnyxData', () => {
},
},
required: true,
+ orderWeight: 3,
},
};
});