diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 86bfa195cd75..4791ad8ebec0 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -137,7 +137,7 @@ jobs: - name: Setup Node id: setup-node uses: ./.github/actions/composite/setupNode - with: + with: IS_HYBRID_BUILD: 'true' - name: Run grunt build @@ -166,8 +166,9 @@ jobs: env: OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }} run: | - op document get --output ./upload-key.keystore upload-key.keystore - op document get --output ./android-fastlane-json-key.json android-fastlane-json-key.json + op read op://Mobile-Deploy-CI/firebase.json/firebase.json --force --out-file ./firebase.json + op read op://Mobile-Deploy-CI/upload-key.keystore/upload-key.keystore --force --out-file ./upload-key.keystore + op read op://Mobile-Deploy-CI/android-fastlane-json-key.json/android-fastlane-json-key.json --force --out-file ./android-fastlane-json-key.json # Copy the keystore to the Android directory for Fullstory cp ./upload-key.keystore Mobile-Expensify/Android @@ -218,7 +219,7 @@ jobs: else echo "Skipping the completion of the previous version rollout" fi - + # Submit the new version for review and slow rollout when it's approved bundle exec fastlane android upload_google_play_production_hybrid_rollout env: @@ -230,6 +231,31 @@ jobs: env: BROWSERSTACK: ${{ secrets.BROWSERSTACK }} + - name: Generate APK from AAB + run: | + json=$(curl -s https://api.github.com/repos/google/bundletool/releases/latest) + downloadUrl=$(echo "$json" | jq -r ".assets | .[].browser_download_url") + curl "$downloadUrl" -4 -sL -o 'bundletool.jar' + java -jar bundletool.jar build-apks --bundle=${{ env.aabPath }} --output=Expensify.apks \ + --mode=universal \ + --ks=upload-key.keystore \ + --ks-pass=pass:${{ steps.load-credentials.outputs.ANDROID_UPLOAD_KEYSTORE_PASSWORD }} \ + --ks-key-alias=${{ steps.load-credentials.outputs.ANDROID_UPLOAD_KEYSTORE_ALIAS }} \ + --key-pass=pass:${{ steps.load-credentials.outputs.ANDROID_UPLOAD_KEY_PASSWORD }} + + unzip -p Expensify.apks universal.apk > Expensify.apk + + - name: Upload Android APK build artifact + if: ${{ !fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} + uses: actions/upload-artifact@v4 + with: + name: android-hybrid-apk-artifact + path: Expensify.apk + + - name: Upload Android build to Firebase distribution + if: ${{ !fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} + run: bundle exec fastlane android upload_firebase_distribution + - name: Upload Android build artifact if: ${{ !fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} uses: actions/upload-artifact@v4 @@ -451,7 +477,7 @@ jobs: - name: Setup Node id: setup-node uses: ./.github/actions/composite/setupNode - with: + with: IS_HYBRID_BUILD: 'true' - name: Setup Ruby @@ -488,9 +514,10 @@ jobs: env: OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }} run: | - op document get --output ./OldApp_AppStore.mobileprovision OldApp_AppStore - op document get --output ./OldApp_AppStore_Share_Extension.mobileprovision OldApp_AppStore_Share_Extension - op document get --output ./OldApp_AppStore_Notification_Service.mobileprovision OldApp_AppStore_Notification_Service + op read op://Mobile-Deploy-CI/firebase.json/firebase.json --force --out-file ./firebase.json + op read op://Mobile-Deploy-CI/OldApp_AppStore/OldApp_AppStore.mobileprovision --force --out-file ./OldApp_AppStore.mobileprovision + op read op://Mobile-Deploy-CI/OldApp_AppStore_Share_Extension/OldApp_AppStore_Share_Extension.mobileprovision --force --out-file ./OldApp_AppStore_Share_Extension.mobileprovision + op read op://Mobile-Deploy-CI/OldApp_AppStore_Notification_Service/OldApp_AppStore_Notification_Service.mobileprovision --force --out-file ./OldApp_AppStore_Notification_Service.mobileprovision - name: Decrypt AppStore profile run: cd ios && gpg --quiet --batch --yes --decrypt --passphrase="$LARGE_SECRET_PASSPHRASE" --output NewApp_AppStore.mobileprovision NewApp_AppStore.mobileprovision.gpg @@ -537,8 +564,8 @@ jobs: run: | # Complete the previous version rollout bundle exec fastlane ios complete_hybrid_rollout - - # Submit the new version for review and phased rollout when it's approved + + # Submit the new version for review and phased rollout when it's approved bundle exec fastlane ios submit_hybrid_for_rollout env: VERSION: ${{ steps.getIOSVersion.outputs.IOS_VERSION }} @@ -549,6 +576,10 @@ jobs: env: BROWSERSTACK: ${{ secrets.BROWSERSTACK }} + - name: Upload iOS build to Firebase distribution + if: ${{ !fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} + run: bundle exec fastlane ios upload_firebase_distribution + - name: Upload iOS build artifact if: ${{ !fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} uses: actions/upload-artifact@v4 @@ -756,6 +787,7 @@ jobs: "./android-sourcemaps-artifact/index.android.bundle.map#android-sourcemap.js.map" "./android-aab-artifact/app-production-release.aab#android.aab" "./android-hybrid-build-artifact/Expensify-release.aab#android-hybrid.aab" + "./android-hybrid-apk-artifact/Expensify.apk#android-hybrid.apk" "./android-hybrid-sourcemap-artifact/index.android.bundle.map#android-hybrid-sourcemap.js.map" "./desktop-staging-sourcemaps-artifact/desktop-staging-merged-source-map.js.map#desktop-staging-sourcemap.js.map" "./desktop-staging-build-artifact/NewExpensify.dmg#desktop-staging.dmg" diff --git a/Gemfile.lock b/Gemfile.lock index 3c0202ff325c..53a966923ee9 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -165,6 +165,9 @@ GEM apktools (~> 0.7) aws-sdk-s3 (~> 1) mime-types (~> 3.3) + fastlane-plugin-firebase_app_distribution (0.10.0) + google-apis-firebaseappdistribution_v1 (~> 0.3.0) + google-apis-firebaseappdistribution_v1alpha (~> 0.2.0) fastlane-sirp (1.0.0) sysrandom (~> 1.0) ffi (1.17.0) @@ -184,6 +187,10 @@ GEM representable (~> 3.0) retriable (>= 2.0, < 4.a) rexml + google-apis-firebaseappdistribution_v1 (0.3.0) + google-apis-core (>= 0.11.0, < 2.a) + google-apis-firebaseappdistribution_v1alpha (0.2.0) + google-apis-core (>= 0.11.0, < 2.a) google-apis-iamcredentials_v1 (0.17.0) google-apis-core (>= 0.11.0, < 2.a) google-apis-playcustomapp_v1 (0.13.0) @@ -229,7 +236,7 @@ GEM molinillo (0.8.0) multi_json (1.15.0) multipart-post (2.4.1) - nanaimo (0.4.0) + nanaimo (0.3.0) nap (1.1.0) naturally (2.2.1) netrc (0.11.0) @@ -274,12 +281,12 @@ GEM uber (0.1.0) unicode-display_width (2.6.0) word_wrap (1.0.0) - xcodeproj (1.27.0) + xcodeproj (1.25.1) CFPropertyList (>= 2.3.3, < 4.0) atomos (~> 0.1.3) claide (>= 1.0.2, < 2.0) colored2 (~> 3.1) - nanaimo (~> 0.4.0) + nanaimo (~> 0.3.0) rexml (>= 3.3.6, < 4.0) xcpretty (0.3.0) rouge (~> 2.0.7) @@ -298,6 +305,7 @@ DEPENDENCIES cocoapods (= 1.15.2) fastlane (~> 2, >= 2.222.0) fastlane-plugin-aws_s3 + fastlane-plugin-firebase_app_distribution xcodeproj (< 1.26.0) xcpretty (~> 0) diff --git a/Mobile-Expensify b/Mobile-Expensify index 9dd1eb09dfa4..d60bc12ab50d 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit 9dd1eb09dfa47da8bcbe6ab0d4bad62d1a628719 +Subproject commit d60bc12ab50dfa1c7c1a1bea4ee717b788678bdd diff --git a/android/app/build.gradle b/android/app/build.gradle index 67f55fb61836..e989d8a48121 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -110,8 +110,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1009008204 - versionName "9.0.82-4" + versionCode 1009008400 + versionName "9.0.84-0" // Supported language variants must be declared here to avoid from being removed during the compilation. // This also helps us to not include unnecessary language variants in the APK. resConfigs "en", "es" diff --git a/docs/articles/expensify-classic/bank-accounts-and-payments/payments/Receive-and-Pay-Bills.md b/docs/articles/expensify-classic/bank-accounts-and-payments/payments/Receive-and-Pay-Bills.md index 964664c1d519..5d1eda3c0819 100644 --- a/docs/articles/expensify-classic/bank-accounts-and-payments/payments/Receive-and-Pay-Bills.md +++ b/docs/articles/expensify-classic/bank-accounts-and-payments/payments/Receive-and-Pay-Bills.md @@ -42,7 +42,7 @@ Expensify makes it easy to manage and pay vendor bills with a straightforward wo ## Bill Pay Workflow 1. **SmartScan & Create**: When a vendor sends a bill, Expensify automatically SmartScans the document and creates a bill. -2. **Submission to Primary Contact**: The bill is submitted to the primary contact, who can review it on the Reports page under their default group policy. +2. **Submission to Primary Contact**: The bill is submitted to the primary contact, who can review it on the Reports page under their default group workspace. 3. **Communication**: If the approver needs clarification, they can communicate directly with the sender via the invoice linked to the bill. 4. **Approval Workflow**: Once reviewed, the bill follows your workspace’s approval process. The final approver handles the payment. 5. **Accounting Integration**: During approval, the bill is coded with the correct GL codes from your connected accounting software. Once approved, it can be exported back to your accounting system. diff --git a/docs/articles/expensify-classic/connections/quickbooks-desktop/Configure-Quickbooks-Desktop.md b/docs/articles/expensify-classic/connections/quickbooks-desktop/Configure-Quickbooks-Desktop.md index 90d3970cbc94..ed390e928fa4 100644 --- a/docs/articles/expensify-classic/connections/quickbooks-desktop/Configure-Quickbooks-Desktop.md +++ b/docs/articles/expensify-classic/connections/quickbooks-desktop/Configure-Quickbooks-Desktop.md @@ -22,9 +22,9 @@ The following steps will determine how data will be exported from Expensify to Q - **Check**: A single itemized check for each Expensify report. - **Journal Entry**: A single itemized journal entry for each Expensify report. - **Non-reimbursable expenses**: Non-reimbursable options include: + - **Credit Card**: Each expense appears as a separate credit card transaction with a post date that matches your credit card statement. If you centrally manage company cards through your domain, you can export expenses from each card to a specific QuickBooks account by clicking Edit Exports next to each user’s card. To display the merchant name in the payee field in QuickBooks Desktop, ensure that a matching Vendor exists in QuickBooks. Expensify searches for an exact match during export. If no match is found, the payee is mapped to a Credit Card Misc. Vendor created by Expensify. + - **Check**: Expenses are exported as individual itemized checks for each Expensify report. The check is written to the “vendor,” which is the person who created or submitted the report in Expensify. - **Vendor Bill**: Each Expensify report results in a single itemized vendor bill. The bill is associated with the “vendor,” which is the individual responsible for creating or submitting the report in Expensify. - - **Credit Card expenses**: Each expense appears as a separate credit card transaction with a post date that matches your credit card statement. If you centrally manage company cards through your domain, you can export expenses from each card to a specific QuickBooks account by clicking Edit Exports next to each user’s card. To display the merchant name in the payee field in QuickBooks Desktop, ensure that a matching Vendor exists in QuickBooks. Expensify searches for an exact match during export. If no match is found, the payee is mapped to a Credit Card Misc. Vendor created by Expensify. - - **Debit Card expenses**: Expenses are exported as individual itemized checks for each Expensify report. The check is written to the “vendor,” which is the person who created or submitted the report in Expensify. # Step 2: Configure coding/import settings diff --git a/docs/articles/expensify-classic/expensify-card/Deactivate-or-cancel-an-Expensify-Card.md b/docs/articles/expensify-classic/expensify-card/Deactivate-or-cancel-an-Expensify-Card.md index d7fa33221834..7a704f024ce7 100644 --- a/docs/articles/expensify-classic/expensify-card/Deactivate-or-cancel-an-Expensify-Card.md +++ b/docs/articles/expensify-classic/expensify-card/Deactivate-or-cancel-an-Expensify-Card.md @@ -4,25 +4,41 @@ description: Close an Expensify Card ---
-A cardholder or a Domain Admin can cancel an Expensify Card. You may want to cancel a card: -- To cancel an old Expensify Card after upgrading to the new Expensify Visa® Commercial Card +A cardholder can cancel an Expensify Card themselves, or a Domain Admin can deactivate it. You may want to cancel or deactivate a card: - After a fraudulent or suspicious charge +- When an Expensify Card is lost or damaged - After an employee leaves the company +# Cardholders + +To cancel an Expensify Card assigned to you, + +1. Hover over Settings, then click **Account**. +2. Click the **Credit Card Import** tab. +3. Click **Request a New Card** next to the card. +4. Choose a reason. +5. Confirm your address details for shipping a new card. +6. Consult this [guide](https://help.expensify.com/articles/expensify-classic/expensify-card/Dispute-A-Transaction) for how to dispute fraudulent transactions (where relevant). + # Domain Admins -To cancel an employee's Expensify Card as a Domain Admin, +To deactivate an employee's Expensify Card as a Domain Admin, 1. Hover over Settings, then click **Domains**. 2. Click the name of the domain. -3. Next to the card, click **Terminate**. +3. Next to the card, click **Edit Limit**. +4. Ensure the Custom Smart Limit toggle is enabled to be able to set a specific card limit. Otherwise, the card limit will be determined by the limit set for the group that the employee is in. +5. In the Limit Amount field, set the limit to $0. The card will be disabled for use until the limit is increased. +6. Click **Save**. -# Cardholders +Note: If you have concerns about fraudulent access to a Domain Admin's user account, please message Concierge or email concierge@expensify.com immediately. If necessary, our support team can manually suspend Expensify cards outside of the Expensify Domain as a temporary measure if your account is compromised. -To cancel an Expensify Card assigned to you, +# Terminating an old Expensify Card after upgrading to the new Expensify Visa® Commercial Card -1. Hover over Settings, then click **Account**. -2. Click the **Credit Card Import** tab. -3. Click **Cancel** next to the card. +To terminate old Expensify Cards that have since been upgraded, + +1. Hover over Settings, then click **Domains**. +2. Click the name of the domain. +3. Next to the card, click **Terminate**.
diff --git a/docs/articles/expensify-classic/settings/Enable-two-factor-authentication.md b/docs/articles/expensify-classic/settings/Enable-two-factor-authentication.md index a507ff263c92..abbd51f3efb7 100644 --- a/docs/articles/expensify-classic/settings/Enable-two-factor-authentication.md +++ b/docs/articles/expensify-classic/settings/Enable-two-factor-authentication.md @@ -39,10 +39,10 @@ When you log in to Expensify in the future, you’ll be emailed a magic code tha If you have lost your mobile device and can’t find your recovery codes, your Domain Admin can complete the steps below to reset your 2FA **only if (1) you use a company email address or email address on a domain that you own and (2) the Domain Admin also has 2FA enabled**: -Go to Settings > Domains > Domain Members and click **Edit Settings** for your email address. -They then click **Reset** to reset two-factor authentication (2FA) on your account. +If your domain has 2FA enabled, a domain admin can follow Settings > Domains > Domain Members and click **Edit Settings** for your email address. +They can then click **Reset** to reset two-factor authentication (2FA) on your account. This will allow you to gain access to your account on the web or mobile app and configure 2FA again. -This will allow you to gain access to your account on the web or mobile app and reconfigure 2FA again. +If your domain does not have 2FA enabled, a domain admin can follow Settings > Domains > Domain Members and enable Two Factor Authentication. Then they can follow the previously mentioned steps to reset 2FA for your account. {% include info.html %} If you use a public email address such as gmail, hotmail, or yahoo, we unfortunately can’t help you disable your 2FA setting. If you are unable to find your recovery codes, you may need to create a new Expensify account with a different email address. diff --git a/docs/articles/expensify-classic/spending-insights/Export-Expenses-And-Reports.md b/docs/articles/expensify-classic/spending-insights/Export-Expenses-And-Reports.md index eac2723e5c9c..5636d1e49f4c 100644 --- a/docs/articles/expensify-classic/spending-insights/Export-Expenses-And-Reports.md +++ b/docs/articles/expensify-classic/spending-insights/Export-Expenses-And-Reports.md @@ -82,7 +82,7 @@ Enter any of the following formulas into the Formula field for each column. Be s | {report:title} | Would output "Expense Expenses to 2019-11-05" assuming that is the report's title.| | Report ID | Number is a unique number per report and can be used to identify specific reports.| | {report:id} | Would output R00I7J3xs5fn assuming that is the report's ID.| -| Old Report ID | A unique number per report and can be used to identify specific reports as well. Every report has both an ID and an old ID - they're simply different ways of showing the same information in either [base10](https://community.expensify.com/home/leaving?allowTrusted=1&target=https%3A%2F%2Fwww.twinkl.co.uk%2Fteaching-wiki%2Fbase-10) or base62. | +| Old Report ID | A unique number per report and can be used to identify specific reports as well. Every report has both an ID and an old ID - they're simply different ways of showing the same information in either base10 or base62. | | {report:oldID} | Would output R3513250790654885 assuming that is the report's old ID.| | Reimbursement ID | The unique number for a report that's been reimbursed via ACH in Expensify. The reimbursement ID is searchable on the Reports page and is found on your bank statement in the line-item detail for the reimbursed amount.| | {report:reimbursementid} | Would output 123456789109876 assuming that is the ID on the line-item detail for the reimbursed amount in your business bank account.| @@ -100,7 +100,7 @@ Enter any of the following formulas into the Formula field for each column. Be s | Created date | The expense report was originally created by the user.| | {report:created} | Would output 2010-09-15 12:00:00 assuming the expense report was created on September 15th, 2010 at noon.| | {report:created:yyyy-MM-dd} | Would output 2010-09-15 assuming the expense report was created on September 15, 2010.| -| | Note - All Date Formulas accept an optional format string. The default if one is not provided is yyyy-MM-dd hh:mm:ss. For a full breakdown, check out the Date Formatting [here](https://community.expensify.com/discussion/5799/deep-dive-date-formating-for-formulas/p1?new=1).| +| | Note - All Date Formulas accept an optional format string. The default if one is not provided is yyyy-MM-dd hh:mm:ss. For a full breakdown, check out the Date Formatting [here](https://help.expensify.com/articles/expensify-classic/spending-insights/Export-Expenses-And-Reports#date-formats).| | StartDate | Is the date of the earliest expense on the report.| | {report:startdate} | Would output 2010-09-15 assuming that is the date of the earliest expense on the report.| | EndDate| Is the date of the last expense on the report.| diff --git a/docs/articles/new-expensify/expenses-&-payments/Approve-expenses.md b/docs/articles/new-expensify/expenses-&-payments/Approve-expenses.md index 77587cc124f0..f933edfd6d3a 100644 --- a/docs/articles/new-expensify/expenses-&-payments/Approve-expenses.md +++ b/docs/articles/new-expensify/expenses-&-payments/Approve-expenses.md @@ -6,19 +6,19 @@ description: Approve, hold, and unapprove submitted expenses Expenses can be created through manual entry, tracking distance, or scanning a receipt. They can be submitted to an individual or a workspace. -This help article has more details about creating and submitting an expense to an individual or a workspace. +This [help article](https://help.expensify.com/articles/new-expensify/expenses-&-payments/Create-an-expense) has more details about creating and submitting an expense to an individual or a workspace. # Receiving an expense from an Individual When an expense is submitted to an individual, it doesn’t need approval. It only needs to be paid. -This help article has the steps to pay the expense. +This [help article](https://help.expensify.com/articles/new-expensify/expenses-&-payments/Pay-an-expense) has the steps to pay the expense. # Receiving a workspace expense -When an expense is submitted to a workspace with an “approval workflow”, it must be approved before it can be paid. +When an expense is submitted to a workspace with an **approval workflow**, it must be approved before it can be paid. -As a workspace admin, you can set an [approval workflow](https://help.expensify.com/articles/new-expensify/workspaces/Add-approvals) in the workspace settings. For each expense report, you’ll have the option to: +A workspace admin can set an [approval workflow](https://help.expensify.com/articles/new-expensify/workspaces/Add-approvals) in the workspace settings. For each expense report, there will be an option to: - **Approve:** Click Approve if you’re satisfied with the expense details. - **Hold the expense:** If you need to delay a payment or provide more information before approval, you can hold an expense. @@ -29,11 +29,11 @@ As a workspace admin, you can set an [approval workflow](https://help.expensify. {% include selector.html values="desktop, mobile" %} {% include option.html value="desktop or WebApp" %} -1. When an expense is submitted, you will receive an email and in-app notification with the details of the expense. +1. When an expense is submitted, the approver will receive an email and in-app notification with the details of the expense. 2. Click the expense in the email to be directed to New Expensify, where you can review it. 3. Click on the expense to view the receipt, amount, description, and additional details the submitter provides. 4. Click **Approve**. -5. When you are ready to pay the expense, follow the steps in this help article. +5. When you are ready to pay the expense, follow the steps in this [help article](https://help.expensify.com/articles/new-expensify/expenses-&-payments/Pay-an-expense). {% include end-option.html %} {% include option.html value="mobile" %} @@ -46,7 +46,7 @@ As a workspace admin, you can set an [approval workflow](https://help.expensify. {% include end-selector.html %} {% include info.html %} -If the transaction is pending (a common occurrence with recent company cards or SmartScan expenses), you’ll need to wait until the transaction posts before approving it. +If the transaction is pending, common for recent Expensify Card transactions or SmartScanning expenses, you’ll need to wait until the transaction posts before approving it. {% include end-info.html %} @@ -68,7 +68,7 @@ When you’re ready to remove the hold, 1. Locate the expense on the Search page. 2. Click **View**. 3. Click the drop-down arrow at the top of the expense. -4. Select **UnHold**. +4. Select **Unhold**. 5. Complete the steps above to “Approve expenses.” Once the expense has been approved, you can pay it. {% include end-option.html %} @@ -85,12 +85,18 @@ When you’re ready to remove the hold, 1. Tap **Search** and select the expense. 2. Tap the drop-down arrow at the top of the expense. -3. Select **UnHold**. +3. Select **Unhold**. 4. Complete the steps above to “Approve expenses.” Once the expense has been approved, you can pay it. {% include end-option.html %} {% include end-selector.html %} +![Use Search to find an expense]({{site.url}}/assets/images/search-hold-01.png){:width="100%"} +![Click on top of expense]({{site.url}}/assets/images/search-hold-02.png){:width="100%"} +![Click Hold]({{site.url}}/assets/images/search-hold-03.png){:width="100%"} +![Click Unhold]({{site.url}}/assets/images/search-hold-04.png){:width="100%"} +![Click Approve]({{site.url}}/assets/images/search-hold-05.png){:width="100%"} + {% include info.html %} Held expenses will not be available for payment until they have been approved. {% include end-info.html %} @@ -125,7 +131,7 @@ If the approved expense has already been exported to an accounting package, you {% include faq-begin.md %} -**Why is an employee expense showing as ‘pending?’** +**Why is an employee expense showing as pending?** An Expensify Card expense will show as pending if the merchant hasn’t posted it. This is usually the case with hotel holds, or card rental holds. A hold will normally last no more than 7-10 business days unless it’s a hotel hold, which can last 31 days. diff --git a/docs/articles/new-expensify/travel/manage-travel-member-roles.md b/docs/articles/new-expensify/travel/Manage-Travel-Member-Roles.md similarity index 88% rename from docs/articles/new-expensify/travel/manage-travel-member-roles.md rename to docs/articles/new-expensify/travel/Manage-Travel-Member-Roles.md index 954e24550f05..33e260cb4d90 100644 --- a/docs/articles/new-expensify/travel/manage-travel-member-roles.md +++ b/docs/articles/new-expensify/travel/Manage-Travel-Member-Roles.md @@ -2,27 +2,23 @@ title: Manage Travel Member Roles description: Modify member roles within Expensify Travel --- -
+
Admins can assign roles to different travel members to determine who they can book travel for (whether for themselves and/or for others) and whether they can adjust administrative settings. - -
- -
To assign a role to a travel member, 1. Click the + icon in the bottom left menu and select **Book travel**. 2. Click **Book or manage travel**. -3. Click the **Program** tab at the top and select Users. +3. Click the **Program** tab at the top and select **Users**. 4. Click the name of the member whose role you wish to update. 5. Click the **Roles** tab and select a role. - **Traveler**: Can only book travel for themselves. - **Travel Arranger**: Can book travel for themselves and for other workspace members. Arrangers can be set to arrange travel for everyone in the workspace or for specific individuals only. - **Company Admin**: Can book travel for themselves as well as any other workspace members. They can also access administrative features to: - Define travel policies - - Add users - - Remove users + - Add Users + - Remove Users - Add and configure corporate cards as payment methods - View analytics and metrics - Use the Safety feature diff --git a/docs/assets/images/profile-picture.png b/docs/assets/images/profile-picture.png new file mode 100644 index 000000000000..5197b6c62b5a Binary files /dev/null and b/docs/assets/images/profile-picture.png differ diff --git a/docs/redirects.csv b/docs/redirects.csv index 04eba2e6152c..be546f165bf1 100644 --- a/docs/redirects.csv +++ b/docs/redirects.csv @@ -609,4 +609,5 @@ https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments https://help.expensify.com/articles/expensify-classic/settings/Set-Notifications,https://help.expensify.com/articles/expensify-classic/settings/Email-Notifications https://help.expensify.com/articles/new-expensify/expenses-&-payments/Export-expenses,https://help.expensify.com/articles/new-expensify/expenses-&-payments/Export-download-expenses https://help.expensify.com/articles/expensify-classic/expenses/Apply-Tax,https://help.expensify.com/articles/expensify-classic/workspaces/Track-Taxes -https://help.expensify.com/articles/expensify-classic/workspaces/Tax-Tracking,https://help.expensify.com/articles/expensify-classic/workspaces/Track-Taxes \ No newline at end of file +https://help.expensify.com/articles/expensify-classic/workspaces/Tax-Tracking,https://help.expensify.com/articles/expensify-classic/workspaces/Track-Taxes +https://help.expensify.com/articles/new-expensify/travel/manage-travel-member-roles,https://help.expensify.com/articles/new-expensify/travel/Manage-Travel-Member-Roles diff --git a/fastlane/Fastfile b/fastlane/Fastfile index d1724e070d72..806ffe574031 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -197,6 +197,17 @@ platform :android do ) end + desc "Upload app to Firebase distribution" + lane :upload_firebase_distribution do + firebase_app_distribution( + app: "1:1008697809946:android:2e48f9ffe8d0b6a2", + service_credentials_file: "./firebase.json", + groups: "applause", + android_artifact_path: ENV[KEY_GRADLE_AAB_PATH], + android_artifact_type: "AAB" + ) + end + desc "Upload HybridApp to Google Play for internal testing" lane :upload_google_play_internal_hybrid do # Google is very unreliable, so we retry a few times @@ -507,6 +518,16 @@ platform :ios do sh("echo '{\"ipa_path\": \"#{lane_context[SharedValues::S3_IPA_OUTPUT_PATH]}\",\"html_path\": \"#{lane_context[SharedValues::S3_HTML_OUTPUT_PATH]}\"}' > ../ios_paths.json") end + desc "Upload app to Firebase distribution" + lane :upload_firebase_distribution do + firebase_app_distribution( + app: "1:1008697809946:ios:3ffad71f664f2886", + service_credentials_file: "./firebase.json", + groups: "applause", + ipa_path: ENV[KEY_IPA_PATH], + ) + end + desc "Upload app to TestFlight" lane :upload_testflight do upload_to_testflight( diff --git a/fastlane/Pluginfile b/fastlane/Pluginfile index c6ab0dfb46a4..3844e2a6958c 100644 --- a/fastlane/Pluginfile +++ b/fastlane/Pluginfile @@ -3,3 +3,4 @@ # Ensure this file is checked in to source control! gem 'fastlane-plugin-aws_s3' +gem 'fastlane-plugin-firebase_app_distribution' diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index ff4c9ff8c999..02f2ac5f3695 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -19,7 +19,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 9.0.82 + 9.0.84 CFBundleSignature ???? CFBundleURLTypes @@ -40,7 +40,7 @@ CFBundleVersion - 9.0.82.4 + 9.0.84.0 FullStory OrgId diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index aab81695759c..20c738fac47a 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -15,10 +15,10 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 9.0.82 + 9.0.84 CFBundleSignature ???? CFBundleVersion - 9.0.82.4 + 9.0.84.0 diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index c6c8a20f6285..8a72d7f91823 100644 --- a/ios/NotificationServiceExtension/Info.plist +++ b/ios/NotificationServiceExtension/Info.plist @@ -11,9 +11,9 @@ CFBundleName $(PRODUCT_NAME) CFBundleShortVersionString - 9.0.82 + 9.0.84 CFBundleVersion - 9.0.82.4 + 9.0.84.0 NSExtension NSExtensionPointIdentifier diff --git a/package-lock.json b/package-lock.json index dda4462daaa2..a375b06b8c13 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,17 +1,17 @@ { "name": "new.expensify", - "version": "9.0.82-4", + "version": "9.0.84-0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "9.0.82-4", + "version": "9.0.84-0", "hasInstallScript": true, "license": "MIT", "dependencies": { "@dotlottie/react-player": "^1.6.3", - "@expensify/react-native-live-markdown": "0.1.210", + "@expensify/react-native-live-markdown": "0.1.215", "@expo/metro-runtime": "^4.0.0", "@firebase/app": "^0.10.10", "@firebase/performance": "^0.6.8", @@ -51,7 +51,7 @@ "date-fns-tz": "^3.2.0", "dom-serializer": "^0.2.2", "domhandler": "^4.3.0", - "expensify-common": "2.0.109", + "expensify-common": "2.0.114", "expo": "52.0.14", "expo-asset": "^11.0.1", "expo-av": "^15.0.1", @@ -3631,9 +3631,9 @@ } }, "node_modules/@expensify/react-native-live-markdown": { - "version": "0.1.210", - "resolved": "https://registry.npmjs.org/@expensify/react-native-live-markdown/-/react-native-live-markdown-0.1.210.tgz", - "integrity": "sha512-CW9DY2yN/QJrqkD6+74s+kWQ9bhWQwd2jT+x5RCgyy5N2SdcoE8G8DGQQvmo6q94KcRkHIr/HsTVOyzACQ/nrw==", + "version": "0.1.215", + "resolved": "https://registry.npmjs.org/@expensify/react-native-live-markdown/-/react-native-live-markdown-0.1.215.tgz", + "integrity": "sha512-DJPZNcWm3y6yrICmjtDta0E1AICNjXYdrw2te70MbfXNpVNL43GodqvdN/TI6Vs0HTPXCOB8x6H3B7Ti4GkM1A==", "hasInstallScript": true, "license": "MIT", "workspaces": [ @@ -21550,9 +21550,9 @@ } }, "node_modules/expensify-common": { - "version": "2.0.109", - "resolved": "https://registry.npmjs.org/expensify-common/-/expensify-common-2.0.109.tgz", - "integrity": "sha512-5XTrJxiDSjQhojnJfXH1G+fSgRM92oAJ5HiLo28HppmJQuA350GOONVo88rRalcI029rlYGIMh8WfhMlOuE/gA==", + "version": "2.0.114", + "resolved": "https://registry.npmjs.org/expensify-common/-/expensify-common-2.0.114.tgz", + "integrity": "sha512-sfGUhZp11x/hDlyJ6GzK5Sr4A5zVV5jVrpOey99IpVXRlpKZMPthsa+ll5S0CJQJro4gE4FBb5qf5kLzADV6Mg==", "license": "MIT", "dependencies": { "awesome-phonenumber": "^5.4.0", diff --git a/package.json b/package.json index db2a31db91d9..c4982998280b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "9.0.82-4", + "version": "9.0.84-0", "author": "Expensify, Inc.", "homepage": "https://new.expensify.com", "description": "New Expensify is the next generation of Expensify: a reimagination of payments based atop a foundation of chat.", @@ -76,7 +76,7 @@ }, "dependencies": { "@dotlottie/react-player": "^1.6.3", - "@expensify/react-native-live-markdown": "0.1.210", + "@expensify/react-native-live-markdown": "0.1.215", "@expo/metro-runtime": "^4.0.0", "@firebase/app": "^0.10.10", "@firebase/performance": "^0.6.8", @@ -116,7 +116,7 @@ "date-fns-tz": "^3.2.0", "dom-serializer": "^0.2.2", "domhandler": "^4.3.0", - "expensify-common": "2.0.109", + "expensify-common": "2.0.114", "expo": "52.0.14", "expo-asset": "^11.0.1", "expo-av": "^15.0.1", diff --git a/patches/react-native-image-picker+7.1.2+002+callback-in-completion-block.patch b/patches/react-native-image-picker+7.1.2+002+callback-in-completion-block.patch new file mode 100644 index 000000000000..6011a582dcf5 --- /dev/null +++ b/patches/react-native-image-picker+7.1.2+002+callback-in-completion-block.patch @@ -0,0 +1,150 @@ +diff --git a/node_modules/react-native-image-picker/ios/ImagePickerManager.mm b/node_modules/react-native-image-picker/ios/ImagePickerManager.mm +index 93e99be..0ef2a8a 100644 +--- a/node_modules/react-native-image-picker/ios/ImagePickerManager.mm ++++ b/node_modules/react-native-image-picker/ios/ImagePickerManager.mm +@@ -506,81 +506,85 @@ @implementation ImagePickerManager (PHPickerViewControllerDelegate) + + - (void)picker:(PHPickerViewController *)picker didFinishPicking:(NSArray *)results API_AVAILABLE(ios(14)) + { +- [picker dismissViewControllerAnimated:YES completion:nil]; +- +- if (photoSelected == YES) { +- return; +- } +- photoSelected = YES; +- +- if (results.count == 0) { +- dispatch_async(dispatch_get_main_queue(), ^{ +- self.callback(@[@{@"didCancel": @YES}]); +- }); +- return; +- } ++ dispatch_block_t dismissCompletionBlock = ^{ + +- dispatch_group_t completionGroup = dispatch_group_create(); +- NSMutableArray *assets = [[NSMutableArray alloc] initWithCapacity:results.count]; +- for (int i = 0; i < results.count; i++) { +- [assets addObject:(NSDictionary *)[NSNull null]]; +- } ++ if (photoSelected == YES) { ++ return; ++ } ++ photoSelected = YES; + +- [results enumerateObjectsUsingBlock:^(PHPickerResult *result, NSUInteger index, BOOL *stop) { +- PHAsset *asset = nil; +- NSItemProvider *provider = result.itemProvider; ++ if (results.count == 0) { ++ dispatch_async(dispatch_get_main_queue(), ^{ ++ self.callback(@[@{@"didCancel": @YES}]); ++ }); ++ return; ++ } + +- // If include extra, we fetch the PHAsset, this required library permissions +- if([self.options[@"includeExtra"] boolValue] && result.assetIdentifier != nil) { +- PHFetchResult* fetchResult = [PHAsset fetchAssetsWithLocalIdentifiers:@[result.assetIdentifier] options:nil]; +- asset = fetchResult.firstObject; ++ dispatch_group_t completionGroup = dispatch_group_create(); ++ NSMutableArray *assets = [[NSMutableArray alloc] initWithCapacity:results.count]; ++ for (int i = 0; i < results.count; i++) { ++ [assets addObject:(NSDictionary *)[NSNull null]]; + } + +- dispatch_group_enter(completionGroup); ++ [results enumerateObjectsUsingBlock:^(PHPickerResult *result, NSUInteger index, BOOL *stop) { ++ PHAsset *asset = nil; ++ NSItemProvider *provider = result.itemProvider; + +- if ([provider hasItemConformingToTypeIdentifier:(NSString *)kUTTypeImage]) { +- NSString *identifier = provider.registeredTypeIdentifiers.firstObject; +- // Matches both com.apple.live-photo-bundle and com.apple.private.live-photo-bundle +- if ([identifier containsString:@"live-photo-bundle"]) { +- // Handle live photos +- identifier = @"public.jpeg"; ++ // If include extra, we fetch the PHAsset, this required library permissions ++ if([self.options[@"includeExtra"] boolValue] && result.assetIdentifier != nil) { ++ PHFetchResult* fetchResult = [PHAsset fetchAssetsWithLocalIdentifiers:@[result.assetIdentifier] options:nil]; ++ asset = fetchResult.firstObject; + } + +- [provider loadFileRepresentationForTypeIdentifier:identifier completionHandler:^(NSURL * _Nullable url, NSError * _Nullable error) { +- NSData *data = [[NSData alloc] initWithContentsOfURL:url]; +- UIImage *image = [[UIImage alloc] initWithData:data]; ++ dispatch_group_enter(completionGroup); + +- assets[index] = [self mapImageToAsset:image data:data phAsset:asset]; +- dispatch_group_leave(completionGroup); +- }]; +- } else if ([provider hasItemConformingToTypeIdentifier:(NSString *)kUTTypeMovie]) { +- [provider loadFileRepresentationForTypeIdentifier:(NSString *)kUTTypeMovie completionHandler:^(NSURL * _Nullable url, NSError * _Nullable error) { +- NSDictionary *mappedAsset = [self mapVideoToAsset:url phAsset:asset error:nil]; +- if (nil != mappedAsset) { +- assets[index] = mappedAsset; ++ if ([provider hasItemConformingToTypeIdentifier:(NSString *)kUTTypeImage]) { ++ NSString *identifier = provider.registeredTypeIdentifiers.firstObject; ++ // Matches both com.apple.live-photo-bundle and com.apple.private.live-photo-bundle ++ if ([identifier containsString:@"live-photo-bundle"]) { ++ // Handle live photos ++ identifier = @"public.jpeg"; + } ++ ++ [provider loadFileRepresentationForTypeIdentifier:identifier completionHandler:^(NSURL * _Nullable url, NSError * _Nullable error) { ++ NSData *data = [[NSData alloc] initWithContentsOfURL:url]; ++ UIImage *image = [[UIImage alloc] initWithData:data]; ++ ++ assets[index] = [self mapImageToAsset:image data:data phAsset:asset]; ++ dispatch_group_leave(completionGroup); ++ }]; ++ } else if ([provider hasItemConformingToTypeIdentifier:(NSString *)kUTTypeMovie]) { ++ [provider loadFileRepresentationForTypeIdentifier:(NSString *)kUTTypeMovie completionHandler:^(NSURL * _Nullable url, NSError * _Nullable error) { ++ NSDictionary *mappedAsset = [self mapVideoToAsset:url phAsset:asset error:nil]; ++ if (nil != mappedAsset) { ++ assets[index] = mappedAsset; ++ } ++ dispatch_group_leave(completionGroup); ++ }]; ++ } else { ++ // The provider didn't have an item matching photo or video (fails on M1 Mac Simulator) + dispatch_group_leave(completionGroup); +- }]; +- } else { +- // The provider didn't have an item matching photo or video (fails on M1 Mac Simulator) +- dispatch_group_leave(completionGroup); +- } +- }]; ++ } ++ }]; + +- dispatch_group_notify(completionGroup, dispatch_get_main_queue(), ^{ +- // mapVideoToAsset can fail and return nil, leaving asset NSNull. +- for (NSDictionary *asset in assets) { +- if ([asset isEqual:[NSNull null]]) { +- self.callback(@[@{@"errorCode": errOthers}]); +- return; ++ dispatch_group_notify(completionGroup, dispatch_get_main_queue(), ^{ ++ // mapVideoToAsset can fail and return nil, leaving asset NSNull. ++ for (NSDictionary *asset in assets) { ++ if ([asset isEqual:[NSNull null]]) { ++ self.callback(@[@{@"errorCode": errOthers}]); ++ return; ++ } + } +- } + +- NSMutableDictionary *response = [[NSMutableDictionary alloc] init]; +- [response setObject:assets forKey:@"assets"]; ++ NSMutableDictionary *response = [[NSMutableDictionary alloc] init]; ++ [response setObject:assets forKey:@"assets"]; ++ ++ self.callback(@[response]); ++ }); ++ }; ++ ++ [picker dismissViewControllerAnimated:YES completion:dismissCompletionBlock]; + +- self.callback(@[response]); +- }); + } + + @end diff --git a/patches/react-native-render-html+6.3.1+002+fix-console-warning.patch b/patches/react-native-render-html+6.3.1+002+fix-console-warning.patch new file mode 100644 index 000000000000..b364e484ba59 --- /dev/null +++ b/patches/react-native-render-html+6.3.1+002+fix-console-warning.patch @@ -0,0 +1,719 @@ +diff --git a/node_modules/react-native-render-html/lib/commonjs/TChildrenRenderer.js b/node_modules/react-native-render-html/lib/commonjs/TChildrenRenderer.js +index 9d16738..bbc66a0 100644 +--- a/node_modules/react-native-render-html/lib/commonjs/TChildrenRenderer.js ++++ b/node_modules/react-native-render-html/lib/commonjs/TChildrenRenderer.js +@@ -3,7 +3,7 @@ + Object.defineProperty(exports, "__esModule", { + value: true + }); +-exports.default = exports.tchildrenRendererDefaultProps = void 0; ++exports.default = void 0; + + var _renderChildren = _interopRequireDefault(require("./renderChildren")); + +@@ -15,15 +15,6 @@ function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { de + */ + const TChildrenRenderer = _renderChildren.default.bind(null); + +-const tchildrenRendererDefaultProps = { +- propsForChildren: {} +-}; +-/** +- * @ignore +- */ +- +-exports.tchildrenRendererDefaultProps = tchildrenRendererDefaultProps; +-TChildrenRenderer.defaultProps = tchildrenRendererDefaultProps; + var _default = TChildrenRenderer; + exports.default = _default; + //# sourceMappingURL=TChildrenRenderer.js.map +\ No newline at end of file +diff --git a/node_modules/react-native-render-html/lib/commonjs/TNodeChildrenRenderer.js b/node_modules/react-native-render-html/lib/commonjs/TNodeChildrenRenderer.js +index 50b43ca..5ecf4a4 100644 +--- a/node_modules/react-native-render-html/lib/commonjs/TNodeChildrenRenderer.js ++++ b/node_modules/react-native-render-html/lib/commonjs/TNodeChildrenRenderer.js +@@ -8,8 +8,6 @@ exports.default = void 0; + + var _SharedPropsProvider = require("./context/SharedPropsProvider"); + +-var _TChildrenRenderer = require("./TChildrenRenderer"); +- + var _renderChildren = _interopRequireDefault(require("./renderChildren")); + + function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } +@@ -78,12 +76,7 @@ function TNodeChildrenRenderer(props) { + + return (0, _renderChildren.default)(useTNodeChildrenProps(props)); + } +-/** +- * @ignore +- */ +- + +-TNodeChildrenRenderer.defaultProps = _TChildrenRenderer.tchildrenRendererDefaultProps; + var _default = TNodeChildrenRenderer; + exports.default = _default; + //# sourceMappingURL=TNodeChildrenRenderer.js.map +\ No newline at end of file +diff --git a/node_modules/react-native-render-html/lib/commonjs/TNodeRenderer.js b/node_modules/react-native-render-html/lib/commonjs/TNodeRenderer.js +index eafc942..e083941 100644 +--- a/node_modules/react-native-render-html/lib/commonjs/TNodeRenderer.js ++++ b/node_modules/react-native-render-html/lib/commonjs/TNodeRenderer.js +@@ -57,7 +57,11 @@ const TNodeRenderer = /*#__PURE__*/(0, _react.memo)(function MemoizedTNodeRender + const sharedProps = (0, _SharedPropsProvider.useSharedProps)(); + const renderRegistry = (0, _RenderRegistryProvider.useRendererRegistry)(); + const TNodeChildrenRenderer = (0, _TChildrenRendererContext.useTNodeChildrenRenderer)(); +- const tnodeProps = { ...props, ++ const tnodeProps = { ++ propsFromParent: { ++ collapsedMarginTop: null ++ }, ++ ...props, + TNodeChildrenRenderer, + sharedProps + }; +@@ -109,13 +113,6 @@ const TNodeRenderer = /*#__PURE__*/(0, _react.memo)(function MemoizedTNodeRender + const renderFn = tnode.type === 'block' || tnode.type === 'document' ? _renderBlockContent.default : _renderTextualContent.default; + return Renderer === null ? renderFn(assembledProps) : /*#__PURE__*/_react.default.createElement(Renderer, assembledProps); + }); +-const defaultProps = { +- propsFromParent: { +- collapsedMarginTop: null +- } +-}; // @ts-expect-error default props must be defined +- +-TNodeRenderer.defaultProps = defaultProps; + var _default = TNodeRenderer; + exports.default = _default; + //# sourceMappingURL=TNodeRenderer.js.map +\ No newline at end of file +diff --git a/node_modules/react-native-render-html/lib/commonjs/TRenderEngineProvider.js b/node_modules/react-native-render-html/lib/commonjs/TRenderEngineProvider.js +index 3a700b6..4011a1b 100644 +--- a/node_modules/react-native-render-html/lib/commonjs/TRenderEngineProvider.js ++++ b/node_modules/react-native-render-html/lib/commonjs/TRenderEngineProvider.js +@@ -5,7 +5,6 @@ Object.defineProperty(exports, "__esModule", { + }); + exports.useAmbientTRenderEngine = useAmbientTRenderEngine; + exports.default = TRenderEngineProvider; +-exports.defaultTRenderEngineProviderProps = exports.defaultFallbackFonts = exports.tRenderEngineProviderPropTypes = void 0; + + var _react = _interopRequireDefault(require("react")); + +@@ -23,38 +22,6 @@ const defaultTRenderEngine = {}; + + const TRenderEngineContext = /*#__PURE__*/_react.default.createContext(defaultTRenderEngine); + +-const tRenderEngineProviderPropTypes = { +- customHTMLElementModels: _propTypes.default.object.isRequired, +- enableCSSInlineProcessing: _propTypes.default.bool, +- enableUserAgentStyles: _propTypes.default.bool, +- idsStyles: _propTypes.default.object, +- ignoredDomTags: _propTypes.default.array, +- ignoreDomNode: _propTypes.default.func, +- domVisitors: _propTypes.default.object, +- ignoredStyles: _propTypes.default.array.isRequired, +- allowedStyles: _propTypes.default.array, +- htmlParserOptions: _propTypes.default.object, +- tagsStyles: _propTypes.default.object, +- classesStyles: _propTypes.default.object, +- emSize: _propTypes.default.number.isRequired, +- baseStyle: _propTypes.default.object, +- systemFonts: _propTypes.default.arrayOf(_propTypes.default.string), +- fallbackFonts: _propTypes.default.shape({ +- serif: _propTypes.default.string, +- 'sans-serif': _propTypes.default.string, +- monospace: _propTypes.default.string +- }), +- setMarkersForTNode: _propTypes.default.func, +- dangerouslyDisableHoisting: _propTypes.default.bool, +- dangerouslyDisableWhitespaceCollapsing: _propTypes.default.bool, +- selectDomRoot: _propTypes.default.func +-}; +-/** +- * Default fallback font for special keys such as 'sans-serif', 'monospace', +- * 'serif', based on current platform. +- */ +- +-exports.tRenderEngineProviderPropTypes = tRenderEngineProviderPropTypes; + const defaultFallbackFonts = { + 'sans-serif': _reactNative.Platform.select({ + ios: 'system', +@@ -70,33 +37,6 @@ const defaultFallbackFonts = { + }) + }; + exports.defaultFallbackFonts = defaultFallbackFonts; +-const defaultTRenderEngineProviderProps = { +- htmlParserOptions: { +- decodeEntities: true +- }, +- emSize: 14, +- ignoredDomTags: [], +- ignoredStyles: [], +- baseStyle: { +- fontSize: 14 +- }, +- tagsStyles: {}, +- classesStyles: {}, +- enableUserAgentStyles: true, +- enableCSSInlineProcessing: true, +- customHTMLElementModels: {}, +- fallbackFonts: defaultFallbackFonts, +- systemFonts: _defaultSystemFonts.default +-}; +-/** +- * Use the ambient transient render engine. +- * +- * @returns The ambient transient render engine. +- * +- * @public +- */ +- +-exports.defaultTRenderEngineProviderProps = defaultTRenderEngineProviderProps; + + function useAmbientTRenderEngine() { + const engine = _react.default.useContext(TRenderEngineContext); +@@ -119,22 +59,26 @@ function useAmbientTRenderEngine() { + + function TRenderEngineProvider({ + children, ++ htmlParserOptions = { ++ decodeEntities: true ++ }, ++ emSize = 14, ++ ignoredDomTags = [], ++ ignoredStyles = [], ++ baseStyle = { fontSize: 14 }, ++ tagsStyles = {}, ++ classesStyles = {}, ++ enableUserAgentStyles = true, ++ enableCSSInlineProcessing = true, ++ customHTMLElementModels = {}, ++ fallbackFonts = defaultFallbackFonts, ++ systemFonts = defaultSystemFonts, + ...config + }) { +- const engine = (0, _useTRenderEngine.default)(config); ++ const engine = (0, _useTRenderEngine.default)({htmlParserOptions, emSize, ignoredDomTags, ignoredStyles, baseStyle, tagsStyles, classesStyles, enableUserAgentStyles, enableCSSInlineProcessing, customHTMLElementModels, fallbackFonts, systemFonts, ...config}); + return /*#__PURE__*/_react.default.createElement(TRenderEngineContext.Provider, { + value: engine + }, children); + } +-/** +- * @ignore +- */ +- +- +-TRenderEngineProvider.defaultProps = defaultTRenderEngineProviderProps; +-/** +- * @ignore +- */ + +-TRenderEngineProvider.propTypes = tRenderEngineProviderPropTypes; + //# sourceMappingURL=TRenderEngineProvider.js.map +\ No newline at end of file +diff --git a/node_modules/react-native-render-html/lib/commonjs/elements/IMGElement.js b/node_modules/react-native-render-html/lib/commonjs/elements/IMGElement.js +index 1be151a..3a076d4 100644 +--- a/node_modules/react-native-render-html/lib/commonjs/elements/IMGElement.js ++++ b/node_modules/react-native-render-html/lib/commonjs/elements/IMGElement.js +@@ -7,8 +7,6 @@ exports.default = void 0; + + var _react = _interopRequireDefault(require("react")); + +-var _propTypes = _interopRequireDefault(require("prop-types")); +- + var _useIMGElementState = _interopRequireDefault(require("./useIMGElementState")); + + var _IMGElementContentSuccess = _interopRequireDefault(require("./IMGElementContentSuccess")); +@@ -19,15 +17,10 @@ var _IMGElementContentLoading = _interopRequireDefault(require("./IMGElementCont + + var _IMGElementContentError = _interopRequireDefault(require("./IMGElementContentError")); + +-var _defaultInitialImageDimensions = _interopRequireDefault(require("./defaultInitialImageDimensions")); +- + function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } + + function _extends() { _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; return _extends.apply(this, arguments); } + +-function identity(arg) { +- return arg; +-} + /** + * A component to render images based on an internal loading state. + * +@@ -37,8 +30,6 @@ function identity(arg) { + * {@link IMGElementContentSuccess}, {@link IMGElementContentLoading} + * and {@link IMGElementContentError} for customization. + */ +- +- + function IMGElement(props) { + const state = (0, _useIMGElementState.default)(props); + let content; +@@ -59,43 +50,6 @@ function IMGElement(props) { + }), content); + } + +-const imgDimensionsType = _propTypes.default.shape({ +- width: _propTypes.default.number, +- height: _propTypes.default.number +-}); +- +-const propTypes = { +- source: _propTypes.default.object.isRequired, +- alt: _propTypes.default.string, +- altColor: _propTypes.default.string, +- height: _propTypes.default.oneOfType([_propTypes.default.string, _propTypes.default.number]), +- width: _propTypes.default.oneOfType([_propTypes.default.string, _propTypes.default.number]), +- style: _propTypes.default.oneOfType([_propTypes.default.object, _propTypes.default.array]), +- computeMaxWidth: _propTypes.default.func.isRequired, +- contentWidth: _propTypes.default.number, +- enableExperimentalPercentWidth: _propTypes.default.bool, +- initialDimensions: imgDimensionsType, +- onPress: _propTypes.default.func, +- testID: _propTypes.default.string, +- objectFit: _propTypes.default.string, +- cachedNaturalDimensions: imgDimensionsType, +- containerProps: _propTypes.default.object +-}; +-/** +- * @ignore +- */ +- +-IMGElement.propTypes = propTypes; +-/** +- * @ignore +- */ +- +-IMGElement.defaultProps = { +- enableExperimentalPercentWidth: false, +- computeMaxWidth: identity, +- imagesInitialDimensions: _defaultInitialImageDimensions.default, +- style: {} +-}; + var _default = IMGElement; + exports.default = _default; + //# sourceMappingURL=IMGElement.js.map +\ No newline at end of file +diff --git a/node_modules/react-native-render-html/src/RenderHTMLConfigProvider.tsx b/node_modules/react-native-render-html/src/RenderHTMLConfigProvider.tsx +index 0df5375..925062a 100644 +--- a/node_modules/react-native-render-html/src/RenderHTMLConfigProvider.tsx ++++ b/node_modules/react-native-render-html/src/RenderHTMLConfigProvider.tsx +@@ -1,5 +1,4 @@ + import React, { PropsWithChildren, ReactElement, useMemo } from 'react'; +-import PropTypes from 'prop-types'; + import RenderersPropsProvider from './context/RenderersPropsProvider'; + import SharedPropsProvider from './context/SharedPropsProvider'; + import TChildrenRenderersContext from './context/TChildrenRendererContext'; +@@ -20,29 +19,6 @@ const childrenRendererContext = { + TNodeChildrenRenderer + }; + +-export type RenderHTMLConfigPropTypes = Record; +- +-export const renderHTMLConfigPropTypes: RenderHTMLConfigPropTypes = { +- bypassAnonymousTPhrasingNodes: PropTypes.bool, +- defaultTextProps: PropTypes.object, +- defaultViewProps: PropTypes.object, +- enableExperimentalBRCollapsing: PropTypes.bool, +- enableExperimentalGhostLinesPrevention: PropTypes.bool, +- enableExperimentalMarginCollapsing: PropTypes.bool, +- remoteErrorView: PropTypes.func, +- remoteLoadingView: PropTypes.func, +- debug: PropTypes.bool, +- computeEmbeddedMaxWidth: PropTypes.func, +- renderersProps: PropTypes.object, +- WebView: PropTypes.any, +- GenericPressable: PropTypes.any, +- defaultWebViewProps: PropTypes.object, +- pressableHightlightColor: PropTypes.string, +- customListStyleSpecs: PropTypes.object, +- renderers: PropTypes.object, +- provideEmbeddedHeaders: PropTypes.func +-}; +- + /** + * A component to provide configuration for {@link RenderHTMLSource} + * descendants, to be used in conjunction with {@link TRenderEngineProvider}. +@@ -85,8 +61,3 @@ export default function RenderHTMLConfigProvider( + + ); + } +- +-/** +- * @ignore +- */ +-RenderHTMLConfigProvider.propTypes = renderHTMLConfigPropTypes; +diff --git a/node_modules/react-native-render-html/src/RenderHTMLSource.tsx b/node_modules/react-native-render-html/src/RenderHTMLSource.tsx +index c91da52..fd0e052 100644 +--- a/node_modules/react-native-render-html/src/RenderHTMLSource.tsx ++++ b/node_modules/react-native-render-html/src/RenderHTMLSource.tsx +@@ -1,7 +1,6 @@ + import equals from 'ramda/src/equals'; + import React, { memo, ReactElement, useMemo } from 'react'; + import { Dimensions } from 'react-native'; +-import PropTypes from 'prop-types'; + import ttreeEventsContext from './context/ttreeEventsContext'; + import isUriSource from './helpers/isUriSource'; + import { SourceLoaderProps, TTreeEvents } from './internal-types'; +@@ -25,29 +24,6 @@ export type RenderHTMLSourcePropTypes = Record< + any + >; + +-export const renderSourcePropTypes: RenderHTMLSourcePropTypes = { +- source: PropTypes.oneOfType([ +- PropTypes.shape({ +- html: PropTypes.string.isRequired, +- baseUrl: PropTypes.string +- }), +- PropTypes.shape({ +- dom: PropTypes.object.isRequired, +- baseUrl: PropTypes.string +- }), +- PropTypes.shape({ +- uri: PropTypes.string.isRequired, +- method: PropTypes.string, +- body: PropTypes.any, +- headers: PropTypes.object +- }) +- ]), +- onTTreeChange: PropTypes.func, +- onHTMLLoaded: PropTypes.func, +- onDocumentMetadataLoaded: PropTypes.func, +- contentWidth: PropTypes.number +-}; +- + function isEmptySource(source: undefined | HTMLSource) { + return ( + !source || +@@ -136,9 +112,4 @@ const RenderHTMLSource = memo( + } + ); + +-/** +- * @ignore +- */ +-(RenderHTMLSource as any).propTypes = renderSourcePropTypes; +- + export default RenderHTMLSource; +diff --git a/node_modules/react-native-render-html/src/TChildrenRenderer.tsx b/node_modules/react-native-render-html/src/TChildrenRenderer.tsx +index 618a592..e12888e 100644 +--- a/node_modules/react-native-render-html/src/TChildrenRenderer.tsx ++++ b/node_modules/react-native-render-html/src/TChildrenRenderer.tsx +@@ -9,16 +9,4 @@ import renderChildren from './renderChildren'; + const TChildrenRenderer: FunctionComponent = + renderChildren.bind(null); + +-export const tchildrenRendererDefaultProps: Pick< +- TChildrenRendererProps, +- 'propsForChildren' +-> = { +- propsForChildren: {} +-}; +- +-/** +- * @ignore +- */ +-TChildrenRenderer.defaultProps = tchildrenRendererDefaultProps; +- + export default TChildrenRenderer; +diff --git a/node_modules/react-native-render-html/src/TNodeChildrenRenderer.tsx b/node_modules/react-native-render-html/src/TNodeChildrenRenderer.tsx +index bf5aef6..b820de0 100644 +--- a/node_modules/react-native-render-html/src/TNodeChildrenRenderer.tsx ++++ b/node_modules/react-native-render-html/src/TNodeChildrenRenderer.tsx +@@ -1,7 +1,6 @@ + import { ReactElement } from 'react'; + import { TNode } from '@native-html/transient-render-engine'; + import { useSharedProps } from './context/SharedPropsProvider'; +-import { tchildrenRendererDefaultProps } from './TChildrenRenderer'; + import { + TChildrenRendererProps, + TNodeChildrenRendererProps +@@ -73,9 +72,4 @@ function TNodeChildrenRenderer( + return renderChildren(useTNodeChildrenProps(props)); + } + +-/** +- * @ignore +- */ +-TNodeChildrenRenderer.defaultProps = tchildrenRendererDefaultProps; +- + export default TNodeChildrenRenderer; +diff --git a/node_modules/react-native-render-html/src/TNodeRenderer.tsx b/node_modules/react-native-render-html/src/TNodeRenderer.tsx +index d32140f..0804ba7 100644 +--- a/node_modules/react-native-render-html/src/TNodeRenderer.tsx ++++ b/node_modules/react-native-render-html/src/TNodeRenderer.tsx +@@ -49,6 +49,7 @@ const TNodeRenderer = memo(function MemoizedTNodeRenderer( + const renderRegistry = useRendererRegistry(); + const TNodeChildrenRenderer = useTNodeChildrenRenderer(); + const tnodeProps = { ++ propsFromParent: { collapsedMarginTop: null }, + ...props, + TNodeChildrenRenderer, + sharedProps +@@ -120,16 +121,6 @@ const TNodeRenderer = memo(function MemoizedTNodeRenderer( + : React.createElement(Renderer as any, assembledProps); + }); + +-const defaultProps: Required, 'propsFromParent'>> = +- { +- propsFromParent: { +- collapsedMarginTop: null +- } +- }; +- +-// @ts-expect-error default props must be defined +-TNodeRenderer.defaultProps = defaultProps; +- + export { + TDefaultBlockRenderer, + TDefaultPhrasingRenderer, +diff --git a/node_modules/react-native-render-html/src/TRenderEngineProvider.tsx b/node_modules/react-native-render-html/src/TRenderEngineProvider.tsx +index 95b60df..db6fe0b 100644 +--- a/node_modules/react-native-render-html/src/TRenderEngineProvider.tsx ++++ b/node_modules/react-native-render-html/src/TRenderEngineProvider.tsx +@@ -1,7 +1,6 @@ + import TRenderEngine from '@native-html/transient-render-engine'; + import React, { PropsWithChildren, ReactElement } from 'react'; + import { Platform } from 'react-native'; +-import PropTypes from 'prop-types'; + import useTRenderEngine from './hooks/useTRenderEngine'; + import { TRenderEngineConfig } from './shared-types'; + import defaultSystemFonts from './defaultSystemFonts'; +@@ -11,36 +10,6 @@ const defaultTRenderEngine = {} as any; + const TRenderEngineContext = + React.createContext(defaultTRenderEngine); + +-export const tRenderEngineProviderPropTypes: Record< +- keyof TRenderEngineConfig, +- any +-> = { +- customHTMLElementModels: PropTypes.object.isRequired, +- enableCSSInlineProcessing: PropTypes.bool, +- enableUserAgentStyles: PropTypes.bool, +- idsStyles: PropTypes.object, +- ignoredDomTags: PropTypes.array, +- ignoreDomNode: PropTypes.func, +- domVisitors: PropTypes.object, +- ignoredStyles: PropTypes.array.isRequired, +- allowedStyles: PropTypes.array, +- htmlParserOptions: PropTypes.object, +- tagsStyles: PropTypes.object, +- classesStyles: PropTypes.object, +- emSize: PropTypes.number.isRequired, +- baseStyle: PropTypes.object, +- systemFonts: PropTypes.arrayOf(PropTypes.string), +- fallbackFonts: PropTypes.shape({ +- serif: PropTypes.string, +- 'sans-serif': PropTypes.string, +- monospace: PropTypes.string +- }), +- setMarkersForTNode: PropTypes.func, +- dangerouslyDisableHoisting: PropTypes.bool, +- dangerouslyDisableWhitespaceCollapsing: PropTypes.bool, +- selectDomRoot: PropTypes.func +-}; +- + /** + * Default fallback font for special keys such as 'sans-serif', 'monospace', + * 'serif', based on current platform. +@@ -51,23 +20,6 @@ export const defaultFallbackFonts = { + serif: Platform.select({ ios: 'Times New Roman', default: 'serif' }) + }; + +-export const defaultTRenderEngineProviderProps: TRenderEngineConfig = { +- htmlParserOptions: { +- decodeEntities: true +- }, +- emSize: 14, +- ignoredDomTags: [], +- ignoredStyles: [], +- baseStyle: { fontSize: 14 }, +- tagsStyles: {}, +- classesStyles: {}, +- enableUserAgentStyles: true, +- enableCSSInlineProcessing: true, +- customHTMLElementModels: {}, +- fallbackFonts: defaultFallbackFonts, +- systemFonts: defaultSystemFonts +-}; +- + /** + * Use the ambient transient render engine. + * +@@ -97,22 +49,26 @@ export function useAmbientTRenderEngine() { + */ + export default function TRenderEngineProvider({ + children, ++ htmlParserOptions = { ++ decodeEntities: true ++ }, ++ emSize = 14, ++ ignoredDomTags = [], ++ ignoredStyles = [], ++ baseStyle = { fontSize: 14 }, ++ tagsStyles = {}, ++ classesStyles = {}, ++ enableUserAgentStyles = true, ++ enableCSSInlineProcessing = true, ++ customHTMLElementModels = {}, ++ fallbackFonts = defaultFallbackFonts, ++ systemFonts = defaultSystemFonts, + ...config + }: PropsWithChildren): ReactElement { +- const engine = useTRenderEngine(config); ++ const engine = useTRenderEngine({htmlParserOptions, emSize, ignoredDomTags, ignoredStyles, baseStyle, tagsStyles, classesStyles, enableUserAgentStyles, enableCSSInlineProcessing, customHTMLElementModels, fallbackFonts, systemFonts, ...config}); + return ( + + {children} + + ); + } +- +-/** +- * @ignore +- */ +-TRenderEngineProvider.defaultProps = defaultTRenderEngineProviderProps; +- +-/** +- * @ignore +- */ +-TRenderEngineProvider.propTypes = tRenderEngineProviderPropTypes; +diff --git a/node_modules/react-native-render-html/src/elements/IMGElement.tsx b/node_modules/react-native-render-html/src/elements/IMGElement.tsx +index 573e7c1..a6fc90b 100644 +--- a/node_modules/react-native-render-html/src/elements/IMGElement.tsx ++++ b/node_modules/react-native-render-html/src/elements/IMGElement.tsx +@@ -1,19 +1,13 @@ + import React, { ReactElement, ReactNode } from 'react'; +-import PropTypes from 'prop-types'; + import useIMGElementState from './useIMGElementState'; + import IMGElementContentSuccess from './IMGElementContentSuccess'; + import IMGElementContainer from './IMGElementContainer'; + import IMGElementContentLoading from './IMGElementContentLoading'; + import IMGElementContentError from './IMGElementContentError'; + import type { IMGElementProps } from './img-types'; +-import defaultImageInitialDimensions from './defaultInitialImageDimensions'; + + export type { IMGElementProps } from './img-types'; + +-function identity(arg: any) { +- return arg; +-} +- + /** + * A component to render images based on an internal loading state. + * +@@ -44,42 +38,4 @@ function IMGElement(props: IMGElementProps): ReactElement { + ); + } + +-const imgDimensionsType = PropTypes.shape({ +- width: PropTypes.number, +- height: PropTypes.number +-}); +- +-const propTypes: Record = { +- source: PropTypes.object.isRequired, +- alt: PropTypes.string, +- altColor: PropTypes.string, +- height: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), +- width: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), +- style: PropTypes.oneOfType([PropTypes.object, PropTypes.array]), +- computeMaxWidth: PropTypes.func.isRequired, +- contentWidth: PropTypes.number, +- enableExperimentalPercentWidth: PropTypes.bool, +- initialDimensions: imgDimensionsType, +- onPress: PropTypes.func, +- testID: PropTypes.string, +- objectFit: PropTypes.string, +- cachedNaturalDimensions: imgDimensionsType, +- containerProps: PropTypes.object +-}; +- +-/** +- * @ignore +- */ +-IMGElement.propTypes = propTypes; +- +-/** +- * @ignore +- */ +-IMGElement.defaultProps = { +- enableExperimentalPercentWidth: false, +- computeMaxWidth: identity, +- imagesInitialDimensions: defaultImageInitialDimensions, +- style: {} +-}; +- + export default IMGElement; +diff --git a/node_modules/react-native-render-html/src/elements/useIMGElementState.ts b/node_modules/react-native-render-html/src/elements/useIMGElementState.ts +index 6590d21..b603f26 100644 +--- a/node_modules/react-native-render-html/src/elements/useIMGElementState.ts ++++ b/node_modules/react-native-render-html/src/elements/useIMGElementState.ts +@@ -63,6 +63,10 @@ function useImageNaturalDimensions

(props: { + }; + } + ++function identity(arg: any) { ++ return arg; ++} ++ + function useFetchedNaturalDimensions(props: { + cachedNaturalDimensions?: ImageDimensions; + source: ImageURISource; +@@ -116,7 +120,7 @@ export default function useIMGElementState( + altColor, + source, + contentWidth, +- computeMaxWidth, ++ computeMaxWidth = identity, + objectFit, + initialDimensions = defaultImageInitialDimensions, + cachedNaturalDimensions +diff --git a/node_modules/react-native-render-html/src/elements/useImageSpecifiedDimensions.ts b/node_modules/react-native-render-html/src/elements/useImageSpecifiedDimensions.ts +index 5d6271b..710c73f 100644 +--- a/node_modules/react-native-render-html/src/elements/useImageSpecifiedDimensions.ts ++++ b/node_modules/react-native-render-html/src/elements/useImageSpecifiedDimensions.ts +@@ -71,8 +71,7 @@ function deriveSpecifiedDimensionsFromProps({ + export default function useImageSpecifiedDimensions( + props: UseIMGElementStateProps + ) { +- const { contentWidth, enableExperimentalPercentWidth, style, width, height } = +- props; ++ const { contentWidth, enableExperimentalPercentWidth = false, style = {}, width, height } = props + const flatStyle = useMemo(() => StyleSheet.flatten(style) || {}, [style]); + const specifiedDimensions = useMemo( + () => +diff --git a/node_modules/react-native-render-html/src/index.ts b/node_modules/react-native-render-html/src/index.ts +index 8569583..b59ec49 100644 +--- a/node_modules/react-native-render-html/src/index.ts ++++ b/node_modules/react-native-render-html/src/index.ts +@@ -128,7 +128,6 @@ export { + export { default as TNodeRenderer } from './TNodeRenderer'; + export { + default as TRenderEngineProvider, +- defaultFallbackFonts, + useAmbientTRenderEngine + } from './TRenderEngineProvider'; + export { default as RenderHTMLConfigProvider } from './RenderHTMLConfigProvider'; +diff --git a/node_modules/react-native-render-html/src/renderChildren.tsx b/node_modules/react-native-render-html/src/renderChildren.tsx +index a669402..be9ffd6 100644 +--- a/node_modules/react-native-render-html/src/renderChildren.tsx ++++ b/node_modules/react-native-render-html/src/renderChildren.tsx +@@ -4,8 +4,6 @@ import TNodeRenderer from './TNodeRenderer'; + import { TChildrenRendererProps } from './shared-types'; + import collapseTopMarginForChild from './helpers/collapseTopMarginForChild'; + +-const empty = {}; +- + const mapCollapsibleChildren = ( + propsForChildren: TChildrenRendererProps['propsForChildren'], + renderChild: TChildrenRendererProps['renderChild'], +@@ -39,7 +37,7 @@ const mapCollapsibleChildren = ( + + export default function renderChildren({ + tchildren, +- propsForChildren = empty, ++ propsForChildren = {}, + disableMarginCollapsing, + renderChild + }: TChildrenRendererProps): ReactElement { diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 062da712cd7f..343523261369 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -895,7 +895,7 @@ const ROUTES = { }, WORKSPACE_PROFILE_DESCRIPTION: { route: 'settings/workspaces/:policyID/profile/description', - getRoute: (policyID: string) => `settings/workspaces/${policyID}/profile/description` as const, + getRoute: (policyID: string | undefined) => `settings/workspaces/${policyID}/profile/description` as const, }, WORKSPACE_PROFILE_SHARE: { route: 'settings/workspaces/:policyID/profile/share', @@ -1017,13 +1017,13 @@ const ROUTES = { getRoute: (policyID: string, categoryName: string) => `settings/workspaces/${policyID}/category/${encodeURIComponent(categoryName)}` as const, }, WORKSPACE_UPGRADE: { - route: 'settings/workspaces/:policyID/upgrade/:featureName?', - getRoute: (policyID: string, featureName?: string, backTo?: string) => - getUrlWithBackToParam(`settings/workspaces/${policyID}/upgrade/${encodeURIComponent(featureName ?? '')}` as const, backTo), + route: 'settings/workspaces/:policyID?/upgrade/:featureName?', + getRoute: (policyID?: string, featureName?: string, backTo?: string) => + policyID ? getUrlWithBackToParam(`settings/workspaces/${policyID}/upgrade/${encodeURIComponent(featureName ?? '')}` as const, backTo) : (`settings/workspaces/upgrade` as const), }, WORKSPACE_DOWNGRADE: { - route: 'settings/workspaces/:policyID/downgrade/', - getRoute: (policyID: string) => `settings/workspaces/${policyID}/downgrade/` as const, + route: 'settings/workspaces/:policyID?/downgrade/', + getRoute: (policyID?: string) => (policyID ? (`settings/workspaces/${policyID}/downgrade/` as const) : `settings/workspaces/downgrade`), }, WORKSPACE_CATEGORIES_SETTINGS: { route: 'settings/workspaces/:policyID/categories/settings', diff --git a/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.tsx b/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.tsx index d211aac7fd4c..b4002767524f 100755 --- a/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.tsx +++ b/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.tsx @@ -49,12 +49,22 @@ function BaseHTMLEngineProvider({textSelectable = false, children, enableExperim }), comment: HTMLElementModel.fromCustomModel({ tagName: 'comment', - mixedUAStyles: {whiteSpace: 'pre'}, + getMixedUAStyles: (tnode) => { + if (tnode.attributes.islarge === undefined) { + return {whiteSpace: 'pre'}; + } + return {whiteSpace: 'pre', ...styles.onlyEmojisText}; + }, contentModel: HTMLContentModel.block, }), 'email-comment': HTMLElementModel.fromCustomModel({ tagName: 'email-comment', - mixedUAStyles: {whiteSpace: 'normal'}, + getMixedUAStyles: (tnode) => { + if (tnode.attributes.islarge === undefined) { + return {whiteSpace: 'normal'}; + } + return {whiteSpace: 'normal', ...styles.onlyEmojisText}; + }, contentModel: HTMLContentModel.block, }), strong: HTMLElementModel.fromCustomModel({ @@ -102,6 +112,7 @@ function BaseHTMLEngineProvider({textSelectable = false, children, enableExperim styles.textSupporting, styles.textLineThrough, styles.mutedNormalTextLabel, + styles.onlyEmojisText, styles.onlyEmojisTextLineHeight, ], ); diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/EditedRenderer.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/EditedRenderer.tsx index 88e5c1f42555..e8d7e0e85fdf 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/EditedRenderer.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/EditedRenderer.tsx @@ -12,9 +12,8 @@ function EditedRenderer({tnode, TDefaultRenderer, style, ...defaultRendererProps const styles = useThemeStyles(); const {translate} = useLocalize(); const isPendingDelete = !!(tnode.attributes.deleted !== undefined); - const isLarge = !!(tnode.attributes.islarge !== undefined); return ( - + void; if (isVisible) { Modal.willAlertModalBecomeVisible(true, type === CONST.MODAL.MODAL_TYPE.POPOVER || type === CONST.MODAL.MODAL_TYPE.BOTTOM_DOCKED); + Keyboard.dismiss(); // To handle closing any modal already visible when this modal is mounted, i.e. PopoverReportActionContextMenu removeOnCloseListener = Modal.setCloseModal(onClose); } diff --git a/src/components/RNMarkdownTextInput.tsx b/src/components/RNMarkdownTextInput.tsx index e8ed0256bf0a..cd4cd86c349e 100644 --- a/src/components/RNMarkdownTextInput.tsx +++ b/src/components/RNMarkdownTextInput.tsx @@ -13,6 +13,17 @@ type AnimatedMarkdownTextInputRef = typeof AnimatedMarkdownTextInput & MarkdownT type RNMarkdownTextInputProps = Omit; +function handleFormatSelection(selectedText: string, formatCommand: string) { + switch (formatCommand) { + case 'formatBold': + return `*${selectedText}*`; + case 'formatItalic': + return `_${selectedText}_`; + default: + return selectedText; + } +} + function RNMarkdownTextInputWithRef({maxLength, ...props}: RNMarkdownTextInputProps, ref: ForwardedRef) { const theme = useTheme(); @@ -28,6 +39,7 @@ function RNMarkdownTextInputWithRef({maxLength, ...props}: RNMarkdownTextInputPr } ref(refHandle as AnimatedMarkdownTextInputRef); }} + formatSelection={handleFormatSelection} // eslint-disable-next-line {...props} /** diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index f5e27896c8e4..03b6c820da00 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -520,6 +520,7 @@ function Search({queryJSON, onSearchListScroll, isSearchScreenFocused, contentCo scrollEventThrottle={1} shouldKeepFocusedItemAtTopOfViewableArea={type === CONST.SEARCH.DATA_TYPES.CHAT} isScreenFocused={isSearchScreenFocused} + initialNumToRender={shouldUseNarrowLayout ? 5 : undefined} /> ); } diff --git a/src/components/SelectionList/BaseSelectionList.tsx b/src/components/SelectionList/BaseSelectionList.tsx index 4738bf6a92d8..f00454099a03 100644 --- a/src/components/SelectionList/BaseSelectionList.tsx +++ b/src/components/SelectionList/BaseSelectionList.tsx @@ -119,6 +119,7 @@ function BaseSelectionList( shouldScrollToFocusedIndex = true, onContentSizeChange, listItemTitleStyles, + initialNumToRender = 12, }: BaseSelectionListProps, ref: ForwardedRef, ) { @@ -754,14 +755,11 @@ function BaseSelectionList( isTextInputFocusedRef.current = isTextInputFocused; }, []); - useImperativeHandle(ref, () => ({scrollAndHighlightItem, clearInputAfterSelect, updateAndScrollToFocusedIndex, updateExternalTextInputFocus, scrollToIndex, getFocusedOption}), [ - scrollAndHighlightItem, - clearInputAfterSelect, - updateAndScrollToFocusedIndex, - updateExternalTextInputFocus, - scrollToIndex, - getFocusedOption, - ]); + useImperativeHandle( + ref, + () => ({scrollAndHighlightItem, clearInputAfterSelect, updateAndScrollToFocusedIndex, updateExternalTextInputFocus, scrollToIndex, getFocusedOption, focusTextInput}), + [scrollAndHighlightItem, clearInputAfterSelect, updateAndScrollToFocusedIndex, updateExternalTextInputFocus, scrollToIndex, getFocusedOption, focusTextInput], + ); /** Selects row when pressing Enter */ useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.ENTER, selectFocusedOption, { @@ -831,7 +829,7 @@ function BaseSelectionList( indicatorStyle="white" keyboardShouldPersistTaps="always" showsVerticalScrollIndicator={showScrollIndicator} - initialNumToRender={12} + initialNumToRender={initialNumToRender} maxToRenderPerBatch={maxToRenderPerBatch} windowSize={windowSize} updateCellsBatchingPeriod={updateCellsBatchingPeriod} diff --git a/src/components/SelectionList/types.ts b/src/components/SelectionList/types.ts index 19c47414b089..213fd71d0632 100644 --- a/src/components/SelectionList/types.ts +++ b/src/components/SelectionList/types.ts @@ -633,6 +633,9 @@ type BaseSelectionListProps = Partial & { /** Called when scrollable content view of the ScrollView changes */ onContentSizeChange?: (w: number, h: number) => void; + + /** Initial number of items to render */ + initialNumToRender?: number; } & TRightHandSideComponent; type SelectionListHandle = { @@ -642,6 +645,7 @@ type SelectionListHandle = { updateAndScrollToFocusedIndex: (newFocusedIndex: number) => void; updateExternalTextInputFocus: (isTextInputFocused: boolean) => void; getFocusedOption: () => ListItem | undefined; + focusTextInput: () => void; }; type ItemLayout = { diff --git a/src/components/VideoPlayer/BaseVideoPlayer.tsx b/src/components/VideoPlayer/BaseVideoPlayer.tsx index 3df4a914f2d9..b0d9bcc44485 100644 --- a/src/components/VideoPlayer/BaseVideoPlayer.tsx +++ b/src/components/VideoPlayer/BaseVideoPlayer.tsx @@ -342,8 +342,21 @@ function BaseVideoPlayer({ shareVideoPlayerElements(videoPlayerRef.current, videoPlayerElementParentRef.current, videoPlayerElementRef.current, isUploading || isFullScreenRef.current); }, [currentlyPlayingURL, shouldUseSharedVideoElement, shareVideoPlayerElements, url, isUploading, isFullScreenRef]); + // Call bindFunctions() through the refs to avoid adding it to the dependency array of the DOM mutation effect, as doing so would change the DOM when the functions update. + const bindFunctionsRef = useRef<(() => void) | null>(null); + const shouldBindFunctionsRef = useRef(false); + + useEffect(() => { + bindFunctionsRef.current = bindFunctions; + if (shouldBindFunctionsRef.current) { + bindFunctions(); + } + }, [bindFunctions]); + // append shared video element to new parent (used for example in attachment modal) useEffect(() => { + shouldBindFunctionsRef.current = false; + if (url !== currentlyPlayingURL || !sharedElement || isFullScreenRef.current) { return; } @@ -360,7 +373,8 @@ function BaseVideoPlayer({ videoPlayerRef.current = currentVideoPlayerRef.current; if (currentlyPlayingURL === url && newParentRef && 'appendChild' in newParentRef) { newParentRef.appendChild(sharedElement as HTMLDivElement); - bindFunctions(); + bindFunctionsRef.current?.(); + shouldBindFunctionsRef.current = true; } return () => { if (!originalParent || !('appendChild' in originalParent)) { @@ -373,7 +387,7 @@ function BaseVideoPlayer({ } newParentRef.childNodes[0]?.remove(); }; - }, [bindFunctions, currentVideoPlayerRef, currentlyPlayingURL, isFullScreenRef, originalParent, sharedElement, shouldUseSharedVideoElement, url]); + }, [currentVideoPlayerRef, currentlyPlayingURL, isFullScreenRef, originalParent, sharedElement, shouldUseSharedVideoElement, url]); useEffect(() => { if (!shouldPlay) { diff --git a/src/hooks/useOnboardingFlow.ts b/src/hooks/useOnboardingFlow.ts index a1a1ed2c00e5..a626606bf3e0 100644 --- a/src/hooks/useOnboardingFlow.ts +++ b/src/hooks/useOnboardingFlow.ts @@ -16,6 +16,7 @@ import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; * Warning: This hook should be used only once in the app */ function useOnboardingFlowRouter() { + const [isLoadingApp] = useOnyx(ONYXKEYS.IS_LOADING_APP, {initialValue: true}); const [isOnboardingCompleted, isOnboardingCompletedMetadata] = useOnyx(ONYXKEYS.NVP_ONBOARDING, { selector: hasCompletedGuidedSetupFlowSelector, }); @@ -32,6 +33,10 @@ function useOnboardingFlowRouter() { useEffect(() => { // This should delay opening the onboarding modal so it does not interfere with the ongoing ReportScreen params changes InteractionManager.runAfterInteractions(() => { + if (isLoadingApp !== false) { + return; + } + if (isLoadingOnyxValue(isOnboardingCompletedMetadata, tryNewDotdMetadata, dismissedProductTrainingMetadata)) { return; } @@ -76,6 +81,7 @@ function useOnboardingFlowRouter() { } }); }, [ + isLoadingApp, isOnboardingCompleted, isHybridAppOnboardingCompleted, isOnboardingCompletedMetadata, diff --git a/src/languages/en.ts b/src/languages/en.ts index 1ff4ef4c0ae4..3c762a0d8dd5 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -523,6 +523,7 @@ const translations = { chooseDocument: 'Choose file', attachmentTooLarge: 'Attachment is too large', sizeExceeded: 'Attachment size is larger than 24 MB limit', + sizeExceededWithLimit: ({maxUploadSizeInMB}: SizeExceededParams) => `Attachment size is larger than ${maxUploadSizeInMB} MB limit`, attachmentTooSmall: 'Attachment is too small', sizeNotMet: 'Attachment size must be greater than 240 bytes', wrongFileType: 'Invalid file type', @@ -2575,6 +2576,7 @@ const translations = { notAuthorized: `You don't have access to this page. If you're trying to join this workspace, just ask the workspace owner to add you as a member. Something else? Reach out to ${CONST.EMAIL.CONCIERGE}.`, goToRoom: ({roomName}: GoToRoomParams) => `Go to ${roomName} room`, goToWorkspace: 'Go to workspace', + goToWorkspaces: 'Go to workspaces', clearFilter: 'Clear filter', workspaceName: 'Workspace name', workspaceOwner: 'Owner', @@ -4383,7 +4385,8 @@ const translations = { title: 'Upgrade to the Control plan', note: 'Unlock our most powerful features, including:', benefits: { - note: 'The Control plan starts at $9 per active member per month.', + startsAt: 'The Control plan starts at ', + perMember: 'per active member per month.', learnMore: 'Learn more', pricing: 'about our plans and pricing.', benefit1: 'Advanced accounting connections (NetSuite, Sage Intacct, and more)', @@ -4410,7 +4413,7 @@ const translations = { }, completed: { headline: 'Your workspace has been downgraded', - description: 'You have other workspace on the Control plan. To be billed at the Collect rate, you must downgrade all workspaces.', + description: 'You have other workspaces on the Control plan. To be billed at the Collect rate, you must downgrade all workspaces.', gotIt: 'Got it, thanks', }, }, diff --git a/src/languages/es.ts b/src/languages/es.ts index 92e94446aa48..bf8205af49d7 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -518,6 +518,7 @@ const translations = { chooseDocument: 'Elegir un archivo', attachmentTooLarge: 'Archivo adjunto demasiado grande', sizeExceeded: 'El archivo adjunto supera el límite de 24 MB.', + sizeExceededWithLimit: ({maxUploadSizeInMB}: SizeExceededParams) => `El archivo adjunto supera el límite de ${maxUploadSizeInMB} MB.`, attachmentTooSmall: 'Archivo adjunto demasiado pequeño', sizeNotMet: 'El archivo adjunto debe ser más grande que 240 bytes.', wrongFileType: 'Tipo de archivo inválido', @@ -2598,6 +2599,7 @@ const translations = { notAuthorized: `No tienes acceso a esta página. Si estás intentando unirte a este espacio de trabajo, pide al dueño del espacio de trabajo que te añada como miembro. ¿Necesitas algo más? Comunícate con ${CONST.EMAIL.CONCIERGE}`, goToRoom: ({roomName}: GoToRoomParams) => `Ir a la sala ${roomName}`, goToWorkspace: 'Ir al espacio de trabajo', + goToWorkspaces: 'Ir a espacios de trabajo', clearFilter: 'Borrar filtro', workspaceName: 'Nombre del espacio de trabajo', workspaceOwner: 'Dueño', @@ -4449,7 +4451,8 @@ const translations = { title: 'Mejorar al plan Controlar', note: 'Desbloquea nuestras funciones más potentes, incluyendo:', benefits: { - note: 'El plan Controlar comienza desde $9 por miembro activo al mes.', + startsAt: 'El plan Controlar comienza desde ', + perMember: 'por miembro activo al mes.', learnMore: 'Más información', pricing: 'sobre nuestros planes y precios.', benefit1: 'Conexiones avanzadas de contabilidad (NetSuite, Sage Intacct y más)', @@ -4476,7 +4479,7 @@ const translations = { }, completed: { headline: 'Tu espacio de trabajo ha sido bajado de categoría', - description: 'Tienes otro espacio de trabajo en el plan Controlar. Para facturarte con la tasa del plan Recopilar, debes bajar de categoría todos los espacios de trabajo.', + description: 'Tienes otros espacios de trabajo en el plan Controlar. Para facturarte con la tasa del plan Recopilar, debes bajar de categoría todos los espacios de trabajo.', gotIt: 'Entendido, gracias.', }, }, diff --git a/src/libs/API/parameters/CategorizeTrackedExpenseParams.ts b/src/libs/API/parameters/CategorizeTrackedExpenseParams.ts index 78eb0adecc5e..d6d8f4169d76 100644 --- a/src/libs/API/parameters/CategorizeTrackedExpenseParams.ts +++ b/src/libs/API/parameters/CategorizeTrackedExpenseParams.ts @@ -1,5 +1,3 @@ -import type {Receipt} from '@src/types/onyx/Transaction'; - type CategorizeTrackedExpenseParams = { amount: number; currency: string; @@ -16,7 +14,6 @@ type CategorizeTrackedExpenseParams = { reportPreviewReportActionID: string; category?: string; tag?: string; - receipt?: Receipt; taxCode: string; taxAmount: number; billable?: boolean; diff --git a/src/libs/API/parameters/ConvertTrackedExpenseToRequestParams.ts b/src/libs/API/parameters/ConvertTrackedExpenseToRequestParams.ts index c51161b043a8..2942923f6b37 100644 --- a/src/libs/API/parameters/ConvertTrackedExpenseToRequestParams.ts +++ b/src/libs/API/parameters/ConvertTrackedExpenseToRequestParams.ts @@ -1,5 +1,3 @@ -import type {Receipt} from '@src/types/onyx/Transaction'; - type ConvertTrackedExpenseToRequestParams = { amount: number; currency: string; @@ -11,7 +9,6 @@ type ConvertTrackedExpenseToRequestParams = { transactionID: string; actionableWhisperReportActionID: string; createdChatReportActionID: string; - receipt?: Receipt; moneyRequestReportID: string; moneyRequestCreatedReportActionID: string; moneyRequestPreviewReportActionID: string; diff --git a/src/libs/API/parameters/ShareTrackedExpenseParams.ts b/src/libs/API/parameters/ShareTrackedExpenseParams.ts index cee4bc40d9ac..dc95d211427b 100644 --- a/src/libs/API/parameters/ShareTrackedExpenseParams.ts +++ b/src/libs/API/parameters/ShareTrackedExpenseParams.ts @@ -1,5 +1,3 @@ -import type {Receipt} from '@src/types/onyx/Transaction'; - type ShareTrackedExpenseParams = { amount: number; currency: string; @@ -16,7 +14,6 @@ type ShareTrackedExpenseParams = { reportPreviewReportActionID: string; category?: string; tag?: string; - receipt?: Receipt; taxCode: string; taxAmount: number; billable?: boolean; diff --git a/src/libs/CategoryUtils.ts b/src/libs/CategoryUtils.ts index 207977ddb000..79ed264ade68 100644 --- a/src/libs/CategoryUtils.ts +++ b/src/libs/CategoryUtils.ts @@ -36,7 +36,7 @@ function formatRequireReceiptsOverText(translate: LocaleContextProps['translate' return translate(`workspace.rules.categoryRules.requireReceiptsOverList.never`); } - const maxExpenseAmountToDisplay = policy?.maxExpenseAmount === CONST.DISABLED_MAX_EXPENSE_VALUE ? 0 : policy?.maxExpenseAmount; + const maxExpenseAmountToDisplay = policy?.maxExpenseAmountNoReceipt === CONST.DISABLED_MAX_EXPENSE_VALUE ? 0 : policy?.maxExpenseAmountNoReceipt; return translate(`workspace.rules.categoryRules.requireReceiptsOverList.default`, { defaultAmount: CurrencyUtils.convertToShortDisplayString(maxExpenseAmountToDisplay, policy?.outputCurrency ?? CONST.CURRENCY.USD), diff --git a/src/libs/Fullstory/index.native.ts b/src/libs/Fullstory/index.native.ts index ad4cfb31f4d3..930c12241b78 100644 --- a/src/libs/Fullstory/index.native.ts +++ b/src/libs/Fullstory/index.native.ts @@ -1,5 +1,6 @@ import FullStory, {FSPage} from '@fullstory/react-native'; import type {OnyxEntry} from 'react-native-onyx'; +import {isExpensifyTeam} from '@libs/PolicyUtils'; import {isConciergeChatReport, shouldUnmaskChat} from '@libs/ReportUtils'; import CONST from '@src/CONST'; import * as Environment from '@src/libs/Environment/Environment'; @@ -42,7 +43,7 @@ const FS = { // UserMetadata onyx key. Environment.getEnvironment().then((envName: string) => { const isTestEmail = value.email !== undefined && value.email.startsWith('fullstory') && value.email.endsWith(CONST.EMAIL.QA_DOMAIN); - if (CONST.ENVIRONMENT.PRODUCTION !== envName && !isTestEmail) { + if ((CONST.ENVIRONMENT.PRODUCTION !== envName && !isTestEmail) || isExpensifyTeam(value?.email)) { return; } FullStory.restart(); diff --git a/src/libs/Fullstory/index.ts b/src/libs/Fullstory/index.ts index 39d2d7e310e5..c7b1c2c9eb7a 100644 --- a/src/libs/Fullstory/index.ts +++ b/src/libs/Fullstory/index.ts @@ -1,5 +1,6 @@ import {FullStory, init, isInitialized} from '@fullstory/browser'; import type {OnyxEntry} from 'react-native-onyx'; +import {isExpensifyTeam} from '@libs/PolicyUtils'; import {isConciergeChatReport, shouldUnmaskChat} from '@libs/ReportUtils'; import CONST from '@src/CONST'; import * as Environment from '@src/libs/Environment/Environment'; @@ -129,7 +130,7 @@ const FS = { try { Environment.getEnvironment().then((envName: string) => { const isTestEmail = value.email !== undefined && value.email.startsWith('fullstory') && value.email.endsWith(CONST.EMAIL.QA_DOMAIN); - if (CONST.ENVIRONMENT.PRODUCTION !== envName && !isTestEmail) { + if ((CONST.ENVIRONMENT.PRODUCTION !== envName && !isTestEmail) || isExpensifyTeam(value?.email)) { return; } FS.onReady().then(() => { diff --git a/src/libs/HttpUtils.ts b/src/libs/HttpUtils.ts index 66ce71451c17..ad3ae653daa1 100644 --- a/src/libs/HttpUtils.ts +++ b/src/libs/HttpUtils.ts @@ -10,6 +10,10 @@ import * as UpdateRequired from './actions/UpdateRequired'; import {READ_COMMANDS, SIDE_EFFECT_REQUEST_COMMANDS, WRITE_COMMANDS} from './API/types'; import * as ApiUtils from './ApiUtils'; import HttpsError from './Errors/HttpsError'; +import getPlatform from './getPlatform'; + +const platform = getPlatform(); +const isNativePlatform = platform === CONST.PLATFORM.ANDROID || platform === CONST.PLATFORM.IOS; let shouldFailAllRequests = false; let shouldForceOffline = false; @@ -160,10 +164,12 @@ function processHTTPRequest(url: string, method: RequestType = 'get', body: Form function xhr(command: string, data: Record, type: RequestType = CONST.NETWORK.METHOD.POST, shouldUseSecure = false): Promise { const formData = new FormData(); Object.keys(data).forEach((key) => { - if (typeof data[key] === 'undefined') { + const value = data[key]; + if (value === undefined) { return; } - formData.append(key, data[key] as string | Blob); + validateFormDataParameter(command, key, value); + formData.append(key, value as string | Blob); }); const url = ApiUtils.getCommandURL({shouldUseSecure, command}); @@ -172,6 +178,35 @@ function xhr(command: string, data: Record, type: RequestType = return processHTTPRequest(url, type, formData, abortSignalController?.signal); } +/** + * Ensures no value of type `object` other than null, Blob, its subclasses, or {uri: string} (native platforms only) is passed to XMLHttpRequest. + * Otherwise, it will be incorrectly serialized as `[object Object]` and cause an error on Android. + * See https://github.com/Expensify/App/issues/45086 + */ +function validateFormDataParameter(command: string, key: string, value: unknown) { + // eslint-disable-next-line @typescript-eslint/no-shadow + const isValid = (value: unknown, isTopLevel: boolean): boolean => { + if (value === null || typeof value !== 'object') { + return true; + } + if (Array.isArray(value)) { + return value.every((element) => isValid(element, false)); + } + if (isTopLevel) { + // Native platforms only require the value to include the `uri` property. + // Optionally, it can also have a `name` and `type` props. + // On other platforms, the value must be an instance of `Blob`. + return isNativePlatform ? 'uri' in value && !!value.uri : value instanceof Blob; + } + return false; + }; + + if (!isValid(value, true)) { + // eslint-disable-next-line no-console + console.warn(`An unsupported value was passed to command '${command}' (parameter: '${key}'). Only Blob and primitive types are allowed.`); + } +} + function cancelPendingRequests(command: AbortCommand = ABORT_COMMANDS.All) { const controller = abortControllerMap.get(command); 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 9a03409fb3a2..2c3b060e0835 100755 --- a/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts +++ b/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts @@ -9,7 +9,6 @@ const FULL_SCREEN_TO_RHP_MAPPING: Partial> = { SCREENS.WORKSPACE.CURRENCY, SCREENS.WORKSPACE.DESCRIPTION, SCREENS.WORKSPACE.SHARE, - SCREENS.WORKSPACE.DOWNGRADE, ], [SCREENS.WORKSPACE.MEMBERS]: [ SCREENS.WORKSPACE.MEMBER_DETAILS, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index ffed2519f775..088f624c7dc3 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -247,13 +247,13 @@ type SettingsNavigatorParamList = { backTo?: Routes; }; [SCREENS.WORKSPACE.UPGRADE]: { - policyID: string; + policyID?: string; featureName?: string; backTo?: Routes; categoryId?: string; }; [SCREENS.WORKSPACE.DOWNGRADE]: { - policyID: string; + policyID?: string; }; [SCREENS.WORKSPACE.CATEGORIES_SETTINGS]: { policyID: string; diff --git a/src/libs/OptionsListUtils.ts b/src/libs/OptionsListUtils.ts index 4d1988a53bde..43f601b167aa 100644 --- a/src/libs/OptionsListUtils.ts +++ b/src/libs/OptionsListUtils.ts @@ -111,7 +111,7 @@ type GetOptionsConfig = { }; type GetUserToInviteConfig = { - searchValue: string; + searchValue: string | undefined; optionsToExclude?: Array>; reportActions?: ReportActions; shouldAcceptName?: boolean; @@ -1078,6 +1078,10 @@ function getUserToInviteOption({ showChatPreviewLine = false, shouldAcceptName = false, }: GetUserToInviteConfig): ReportUtils.OptionData | null { + if (!searchValue) { + return null; + } + const parsedPhoneNumber = PhoneNumber.parsePhoneNumber(LoginUtils.appendCountryCode(Str.removeSMSDomain(searchValue))); const isCurrentUserLogin = isCurrentUser({login: searchValue} as PersonalDetails); const isInSelectedOption = selectedOptions.some((option) => 'login' in option && option.login === searchValue); @@ -1086,7 +1090,7 @@ function getUserToInviteOption({ const isInOptionToExclude = optionsToExclude.findIndex((optionToExclude) => 'login' in optionToExclude && optionToExclude.login === PhoneNumber.addSMSDomainIfPhoneNumber(searchValue).toLowerCase()) !== -1; - if (!searchValue || isCurrentUserLogin || isInSelectedOption || (!isValidEmail && !isValidPhoneNumber && !shouldAcceptName) || isInOptionToExclude) { + if (isCurrentUserLogin || isInSelectedOption || (!isValidEmail && !isValidPhoneNumber && !shouldAcceptName) || isInOptionToExclude) { return null; } diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts index 2a4168a24668..672e79ff335f 100644 --- a/src/libs/PolicyUtils.ts +++ b/src/libs/PolicyUtils.ts @@ -34,6 +34,7 @@ import type PolicyEmployee from '@src/types/onyx/PolicyEmployee'; import type {SearchPolicy} from '@src/types/onyx/SearchResults'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import {hasSynchronizationErrorMessage} from './actions/connections'; +import {getCurrentUserAccountID} from './actions/Report'; import {getCategoryApproverRule} from './CategoryUtils'; import * as Localize from './Localize'; import Navigation from './Navigation/Navigation'; @@ -687,11 +688,6 @@ function canSendInvoice(policies: OnyxCollection | null, currentUserLogi return getActiveAdminWorkspaces(policies, currentUserLogin).some((policy) => canSendInvoiceFromWorkspace(policy.id)); } -function hasWorkspaceWithInvoices(currentUserLogin: string | undefined): boolean { - const activePolicies = getActivePolicies(allPolicies, currentUserLogin); - return activePolicies.some((policy) => shouldShowPolicy(policy, NetworkStore.isOffline(), currentUserLogin) && policy.areInvoicesEnabled); -} - function hasDependentTags(policy: OnyxEntry, policyTagList: OnyxEntry) { if (!policy?.hasMultipleTagLists) { return false; @@ -1190,6 +1186,25 @@ function hasOtherControlWorkspaces(currentPolicyID: string) { return otherControlWorkspaces.length > 0; } +// If no policyID is provided, it indicates the workspace upgrade/downgrade URL +// is being accessed from the Subscriptions page without a specific policyID. +// In this case, check if the user is an admin on more than one policy. +// If the user is an admin for multiple policies, we can render the page as it contains a condition +// to navigate them to the Workspaces page when no policyID is provided, instead of showing the Upgrade/Downgrade button. +// If the user is not an admin for multiple policies, they are not allowed to perform this action, and the NotFoundPage is displayed. + +function canModifyPlan(policyID?: string) { + const currentUserAccountID = getCurrentUserAccountID(); + const ownerPolicies = getOwnedPaidPolicies(allPolicies, currentUserAccountID); + + if (!policyID) { + return ownerPolicies.length > 1; + } + const policy = getPolicy(policyID); + + return !!policy && isPolicyAdmin(policy); +} + export { canEditTaxRate, extractPolicyIDFromPath, @@ -1249,7 +1264,6 @@ export { canSendInvoiceFromWorkspace, canSubmitPerDiemExpenseFromWorkspace, canSendInvoice, - hasWorkspaceWithInvoices, hasDependentTags, hasVBBA, getXeroTenants, @@ -1318,6 +1332,7 @@ export { hasOtherControlWorkspaces, getManagerAccountEmail, getRuleApprovers, + canModifyPlan, }; export type {MemberEmailsToAccountIDs}; diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 8a5ae8b1d102..67e0828f5c61 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -4957,7 +4957,7 @@ function getRejectedReportMessage() { function getWorkspaceNameUpdatedMessage(action: ReportAction) { const {oldName, newName} = ReportActionsUtils.getOriginalMessage(action as ReportAction) ?? {}; const message = oldName && newName ? Localize.translateLocal('workspaceActions.renamedWorkspaceNameAction', {oldName, newName}) : ReportActionsUtils.getReportActionText(action); - return message; + return Str.htmlEncode(message); } /** @@ -8349,13 +8349,11 @@ function createDraftTransactionAndNavigateToParticipantSelector( mccGroup, } as Transaction); - const filteredPolicies = Object.values(allPolicies ?? {}).filter( - (policy) => policy && policy.type !== CONST.POLICY.TYPE.PERSONAL && policy.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, - ); + const filteredPolicies = Object.values(allPolicies ?? {}).filter((policy) => PolicyUtils.shouldShowPolicy(policy, false, currentUserEmail)); if (actionName === CONST.IOU.ACTION.CATEGORIZE) { const activePolicy = getPolicy(activePolicyID); - if (activePolicy && activePolicy?.type !== CONST.POLICY.TYPE.PERSONAL && activePolicy?.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { + if (PolicyUtils.shouldShowPolicy(activePolicy, false, currentUserEmail)) { const policyExpenseReportID = getPolicyExpenseChat(currentUserAccountID, activePolicyID)?.reportID; IOU.setMoneyRequestParticipants(transactionID, [ { diff --git a/src/libs/SearchParser/autocompleteParser.js b/src/libs/SearchParser/autocompleteParser.js index 0b456b5823b1..d8705428bc1f 100644 --- a/src/libs/SearchParser/autocompleteParser.js +++ b/src/libs/SearchParser/autocompleteParser.js @@ -212,13 +212,13 @@ function peg$parse(input, options) { var peg$c34 = ">"; var peg$c35 = "<="; var peg$c36 = "<"; - var peg$c37 = "\""; var peg$r0 = /^[:=]/; - var peg$r1 = /^[^ ,"\t\n\r]/; - var peg$r2 = /^[^"\r\n]/; - var peg$r3 = /^[^ ,\t\n\r]/; - var peg$r4 = /^[ \t\r\n]/; + var peg$r1 = /^[^ ,"\u201D\u201C\t\n\r]/; + var peg$r2 = /^["\u201C-\u201D]/; + var peg$r3 = /^[^"\u201D\u201C\r\n]/; + var peg$r4 = /^[^ ,\t\n\r]/; + var peg$r5 = /^[ \t\r\n]/; var peg$e0 = peg$literalExpectation(",", false); var peg$e1 = peg$otherExpectation("key"); @@ -261,9 +261,9 @@ function peg$parse(input, options) { var peg$e38 = peg$literalExpectation("<=", false); var peg$e39 = peg$literalExpectation("<", false); var peg$e40 = peg$otherExpectation("quote"); - var peg$e41 = peg$classExpectation([" ", ",", "\"", "\t", "\n", "\r"], true, false); - var peg$e42 = peg$literalExpectation("\"", false); - var peg$e43 = peg$classExpectation(["\"", "\r", "\n"], true, false); + var peg$e41 = peg$classExpectation([" ", ",", "\"", "\u201D", "\u201C", "\t", "\n", "\r"], true, false); + var peg$e42 = peg$classExpectation(["\"", ["\u201C", "\u201D"]], false, false); + var peg$e43 = peg$classExpectation(["\"", "\u201D", "\u201C", "\r", "\n"], true, false); var peg$e44 = peg$classExpectation([" ", ",", "\t", "\n", "\r"], true, false); var peg$e45 = peg$otherExpectation("word"); var peg$e46 = peg$otherExpectation("whitespace"); @@ -1418,8 +1418,8 @@ function peg$parse(input, options) { if (peg$silentFails === 0) { peg$fail(peg$e41); } } } - if (input.charCodeAt(peg$currPos) === 34) { - s2 = peg$c37; + s2 = input.charAt(peg$currPos); + if (peg$r2.test(s2)) { peg$currPos++; } else { s2 = peg$FAILED; @@ -1428,7 +1428,7 @@ function peg$parse(input, options) { if (s2 !== peg$FAILED) { s3 = []; s4 = input.charAt(peg$currPos); - if (peg$r2.test(s4)) { + if (peg$r3.test(s4)) { peg$currPos++; } else { s4 = peg$FAILED; @@ -1437,15 +1437,15 @@ function peg$parse(input, options) { while (s4 !== peg$FAILED) { s3.push(s4); s4 = input.charAt(peg$currPos); - if (peg$r2.test(s4)) { + if (peg$r3.test(s4)) { peg$currPos++; } else { s4 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e43); } } } - if (input.charCodeAt(peg$currPos) === 34) { - s4 = peg$c37; + s4 = input.charAt(peg$currPos); + if (peg$r2.test(s4)) { peg$currPos++; } else { s4 = peg$FAILED; @@ -1454,7 +1454,7 @@ function peg$parse(input, options) { if (s4 !== peg$FAILED) { s5 = []; s6 = input.charAt(peg$currPos); - if (peg$r3.test(s6)) { + if (peg$r4.test(s6)) { peg$currPos++; } else { s6 = peg$FAILED; @@ -1463,7 +1463,7 @@ function peg$parse(input, options) { while (s6 !== peg$FAILED) { s5.push(s6); s6 = input.charAt(peg$currPos); - if (peg$r3.test(s6)) { + if (peg$r4.test(s6)) { peg$currPos++; } else { s6 = peg$FAILED; @@ -1496,7 +1496,7 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = []; s2 = input.charAt(peg$currPos); - if (peg$r3.test(s2)) { + if (peg$r4.test(s2)) { peg$currPos++; } else { s2 = peg$FAILED; @@ -1506,7 +1506,7 @@ function peg$parse(input, options) { while (s2 !== peg$FAILED) { s1.push(s2); s2 = input.charAt(peg$currPos); - if (peg$r3.test(s2)) { + if (peg$r4.test(s2)) { peg$currPos++; } else { s2 = peg$FAILED; @@ -1548,7 +1548,7 @@ function peg$parse(input, options) { peg$silentFails++; s0 = []; s1 = input.charAt(peg$currPos); - if (peg$r4.test(s1)) { + if (peg$r5.test(s1)) { peg$currPos++; } else { s1 = peg$FAILED; @@ -1557,7 +1557,7 @@ function peg$parse(input, options) { while (s1 !== peg$FAILED) { s0.push(s1); s1 = input.charAt(peg$currPos); - if (peg$r4.test(s1)) { + if (peg$r5.test(s1)) { peg$currPos++; } else { s1 = peg$FAILED; diff --git a/src/libs/SearchParser/baseRules.peggy b/src/libs/SearchParser/baseRules.peggy index cc1305adc8b3..ebde336a6ead 100644 --- a/src/libs/SearchParser/baseRules.peggy +++ b/src/libs/SearchParser/baseRules.peggy @@ -56,7 +56,7 @@ operator "operator" / "<" { return "lt"; } quotedString "quote" - = start:[^ ,"\t\n\r]* "\"" inner:[^"\r\n]* "\"" end:[^ ,\t\n\r]* { + = start:[^ ,"”“\t\n\r]* ("“" / "\"" / "”") inner:[^"”“\r\n]* ("“" / "\"" / "”") end:[^ ,\t\n\r]* { return [...start, '"', ...inner, '"', ...end].join(""); } diff --git a/src/libs/SearchParser/searchParser.js b/src/libs/SearchParser/searchParser.js index 941ac7f59797..d9baeadb2a14 100644 --- a/src/libs/SearchParser/searchParser.js +++ b/src/libs/SearchParser/searchParser.js @@ -217,14 +217,14 @@ function peg$parse(input, options) { var peg$c34 = ">"; var peg$c35 = "<="; var peg$c36 = "<"; - var peg$c37 = "\""; var peg$r0 = /^[^ \t\r\n]/; var peg$r1 = /^[:=]/; - var peg$r2 = /^[^ ,"\t\n\r]/; - var peg$r3 = /^[^"\r\n]/; - var peg$r4 = /^[^ ,\t\n\r]/; - var peg$r5 = /^[ \t\r\n]/; + var peg$r2 = /^[^ ,"\u201D\u201C\t\n\r]/; + var peg$r3 = /^["\u201C-\u201D]/; + var peg$r4 = /^[^"\u201D\u201C\r\n]/; + var peg$r5 = /^[^ ,\t\n\r]/; + var peg$r6 = /^[ \t\r\n]/; var peg$e0 = peg$classExpectation([" ", "\t", "\r", "\n"], true, false); var peg$e1 = peg$otherExpectation("key"); @@ -269,9 +269,9 @@ function peg$parse(input, options) { var peg$e40 = peg$literalExpectation("<=", false); var peg$e41 = peg$literalExpectation("<", false); var peg$e42 = peg$otherExpectation("quote"); - var peg$e43 = peg$classExpectation([" ", ",", "\"", "\t", "\n", "\r"], true, false); - var peg$e44 = peg$literalExpectation("\"", false); - var peg$e45 = peg$classExpectation(["\"", "\r", "\n"], true, false); + var peg$e43 = peg$classExpectation([" ", ",", "\"", "\u201D", "\u201C", "\t", "\n", "\r"], true, false); + var peg$e44 = peg$classExpectation(["\"", ["\u201C", "\u201D"]], false, false); + var peg$e45 = peg$classExpectation(["\"", "\u201D", "\u201C", "\r", "\n"], true, false); var peg$e46 = peg$classExpectation([" ", ",", "\t", "\n", "\r"], true, false); var peg$e47 = peg$otherExpectation("word"); var peg$e48 = peg$otherExpectation("whitespace"); @@ -1616,8 +1616,8 @@ function peg$parse(input, options) { if (peg$silentFails === 0) { peg$fail(peg$e43); } } } - if (input.charCodeAt(peg$currPos) === 34) { - s2 = peg$c37; + s2 = input.charAt(peg$currPos); + if (peg$r3.test(s2)) { peg$currPos++; } else { s2 = peg$FAILED; @@ -1626,7 +1626,7 @@ function peg$parse(input, options) { if (s2 !== peg$FAILED) { s3 = []; s4 = input.charAt(peg$currPos); - if (peg$r3.test(s4)) { + if (peg$r4.test(s4)) { peg$currPos++; } else { s4 = peg$FAILED; @@ -1635,15 +1635,15 @@ function peg$parse(input, options) { while (s4 !== peg$FAILED) { s3.push(s4); s4 = input.charAt(peg$currPos); - if (peg$r3.test(s4)) { + if (peg$r4.test(s4)) { peg$currPos++; } else { s4 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e45); } } } - if (input.charCodeAt(peg$currPos) === 34) { - s4 = peg$c37; + s4 = input.charAt(peg$currPos); + if (peg$r3.test(s4)) { peg$currPos++; } else { s4 = peg$FAILED; @@ -1652,7 +1652,7 @@ function peg$parse(input, options) { if (s4 !== peg$FAILED) { s5 = []; s6 = input.charAt(peg$currPos); - if (peg$r4.test(s6)) { + if (peg$r5.test(s6)) { peg$currPos++; } else { s6 = peg$FAILED; @@ -1661,7 +1661,7 @@ function peg$parse(input, options) { while (s6 !== peg$FAILED) { s5.push(s6); s6 = input.charAt(peg$currPos); - if (peg$r4.test(s6)) { + if (peg$r5.test(s6)) { peg$currPos++; } else { s6 = peg$FAILED; @@ -1694,7 +1694,7 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = []; s2 = input.charAt(peg$currPos); - if (peg$r4.test(s2)) { + if (peg$r5.test(s2)) { peg$currPos++; } else { s2 = peg$FAILED; @@ -1704,7 +1704,7 @@ function peg$parse(input, options) { while (s2 !== peg$FAILED) { s1.push(s2); s2 = input.charAt(peg$currPos); - if (peg$r4.test(s2)) { + if (peg$r5.test(s2)) { peg$currPos++; } else { s2 = peg$FAILED; @@ -1746,7 +1746,7 @@ function peg$parse(input, options) { peg$silentFails++; s0 = []; s1 = input.charAt(peg$currPos); - if (peg$r5.test(s1)) { + if (peg$r6.test(s1)) { peg$currPos++; } else { s1 = peg$FAILED; @@ -1755,7 +1755,7 @@ function peg$parse(input, options) { while (s1 !== peg$FAILED) { s0.push(s1); s1 = input.charAt(peg$currPos); - if (peg$r5.test(s1)) { + if (peg$r6.test(s1)) { peg$currPos++; } else { s1 = peg$FAILED; diff --git a/src/libs/SearchQueryUtils.ts b/src/libs/SearchQueryUtils.ts index 73c83cb33b83..3aad00f815ba 100644 --- a/src/libs/SearchQueryUtils.ts +++ b/src/libs/SearchQueryUtils.ts @@ -67,11 +67,10 @@ const UserFriendlyKeyMap: Record { + preservedShouldUseStagingServer = value?.shouldUseStagingServer; + }, +}); + const KEYS_TO_PRESERVE: OnyxKey[] = [ ONYXKEYS.ACCOUNT, ONYXKEYS.IS_CHECKING_PUBLIC_ROOM, @@ -540,6 +548,7 @@ function setPreservedUserSession(session: OnyxTypes.Session) { function clearOnyxAndResetApp(shouldNavigateToHomepage?: boolean) { // The value of isUsingImportedState will be lost once Onyx is cleared, so we need to store it const isStateImported = isUsingImportedState; + const shouldUseStagingServer = preservedShouldUseStagingServer; const sequentialQueue = PersistedRequests.getAll(); Onyx.clear(KEYS_TO_PRESERVE).then(() => { // Network key is preserved, so when using imported state, we should stop forcing offline mode so that the app can re-fetch the network @@ -556,6 +565,10 @@ function clearOnyxAndResetApp(shouldNavigateToHomepage?: boolean) { Onyx.set(ONYXKEYS.PRESERVED_USER_SESSION, null); } + if (shouldUseStagingServer) { + Onyx.set(ONYXKEYS.USER, {shouldUseStagingServer}); + } + // Requests in a sequential queue should be called even if the Onyx state is reset, so we do not lose any pending data. // However, the OpenApp request must be called before any other request in a queue to ensure data consistency. // To do that, sequential queue is cleared together with other keys, and then it's restored once the OpenApp request is resolved. diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts index a7d27fea72de..980c9e79b1a5 100644 --- a/src/libs/actions/IOU.ts +++ b/src/libs/actions/IOU.ts @@ -126,7 +126,6 @@ type CategorizeTrackedExpenseTransactionParams = { category?: string; tag?: string; billable?: boolean; - receipt?: Receipt; }; type CategorizeTrackedExpensePolicyParams = { policyID: string; @@ -3743,7 +3742,6 @@ function convertTrackedExpenseToRequest( merchant: string, created: string, attendees?: Attendee[], - receipt?: Receipt, ) { const {optimisticData, successData, failureData} = onyxData; @@ -3773,7 +3771,6 @@ function convertTrackedExpenseToRequest( comment, created, merchant, - receipt, payerAccountID, payerEmail, chatReportID, @@ -3815,10 +3812,10 @@ function categorizeTrackedExpense(trackedExpenseParams: CategorizeTrackedExpense successData?.push(...moveTransactionSuccessData); failureData?.push(...moveTransactionFailureData); const parameters = { - onyxData, ...reportInformation, ...policyParams, ...transactionParams, + linkedTrackedExpenseReportAction: undefined, modifiedExpenseReportActionID, policyExpenseChatReportID: createdWorkspaceParams?.expenseChatReportID, policyExpenseCreatedReportActionID: createdWorkspaceParams?.expenseCreatedReportActionID, @@ -3857,7 +3854,6 @@ function shareTrackedExpense( taxCode = '', taxAmount = 0, billable?: boolean, - receipt?: Receipt, createdWorkspaceParams?: CreateWorkspaceParams, ) { const {optimisticData, successData, failureData} = onyxData ?? {}; @@ -3900,7 +3896,6 @@ function shareTrackedExpense( taxCode, taxAmount, billable, - receipt, policyExpenseChatReportID: createdWorkspaceParams?.expenseChatReportID, policyExpenseCreatedReportActionID: createdWorkspaceParams?.expenseCreatedReportActionID, adminsChatReportID: createdWorkspaceParams?.adminsChatReportID, @@ -3993,7 +3988,6 @@ function requestMoney(requestMoneyInformation: RequestMoneyInformation) { merchant, created, attendees, - receipt, ); break; } @@ -4013,7 +4007,7 @@ function requestMoney(requestMoneyInformation: RequestMoneyInformation) { createdChatReportActionID, createdIOUReportActionID, reportPreviewReportActionID: reportPreviewAction.reportActionID, - receipt, + receipt: receipt instanceof Blob ? receipt : undefined, receiptState: receipt?.state, category, tag, @@ -4192,7 +4186,7 @@ function trackExpense( if (!linkedTrackedExpenseReportAction || !actionableWhisperReportActionID || !linkedTrackedExpenseReportID) { return; } - const transactionParams = { + const transactionParams: CategorizeTrackedExpenseTransactionParams = { transactionID: transaction?.transactionID ?? '-1', amount, currency, @@ -4204,13 +4198,12 @@ function trackExpense( category, tag, billable, - receipt: trackedReceipt, }; - const policyParams = { + const policyParams: CategorizeTrackedExpensePolicyParams = { policyID: chatReport?.policyID ?? '-1', isDraftPolicy, }; - const reportInformation = { + const reportInformation: CategorizeTrackedExpenseReportInformation = { moneyRequestPreviewReportActionID: iouAction?.reportActionID ?? '-1', moneyRequestReportID: iouReport?.reportID ?? '-1', moneyRequestCreatedReportActionID: createdIOUReportActionID ?? '-1', @@ -4220,7 +4213,7 @@ function trackExpense( transactionThreadReportID: transactionThreadReportID ?? '-1', reportPreviewReportActionID: reportPreviewAction?.reportActionID ?? '-1', }; - const trackedExpenseParams = { + const trackedExpenseParams: CategorizeTrackedExpenseParams = { onyxData, reportInformation, transactionParams, @@ -4257,7 +4250,6 @@ function trackExpense( taxCode, taxAmount, billable, - trackedReceipt, createdWorkspaceParams, ); break; @@ -4276,7 +4268,7 @@ function trackExpense( createdChatReportActionID: createdChatReportActionID ?? '-1', createdIOUReportActionID, reportPreviewReportActionID: reportPreviewAction?.reportActionID, - receipt: trackedReceipt, + receipt: trackedReceipt instanceof Blob ? trackedReceipt : undefined, receiptState: trackedReceipt?.state, category, tag, diff --git a/src/pages/ConciergePage.tsx b/src/pages/ConciergePage.tsx index 0ce089787230..c4de1e3b4062 100644 --- a/src/pages/ConciergePage.tsx +++ b/src/pages/ConciergePage.tsx @@ -41,7 +41,7 @@ function ConciergePage() { } // Mark the viewTourTask as complete if we are redirected to Concierge after finishing the Navattic tour - const {navattic} = route.params as {navattic?: string}; + const {navattic} = (route.params as {navattic?: string}) ?? {}; if (navattic === CONST.NAVATTIC.COMPLETED) { if (viewTourTaskReport) { if (viewTourTaskReport.stateNum !== CONST.REPORT.STATE_NUM.APPROVED || viewTourTaskReport.statusNum !== CONST.REPORT.STATUS_NUM.APPROVED) { diff --git a/src/pages/NewChatConfirmPage.tsx b/src/pages/NewChatConfirmPage.tsx index 85e33cf0c598..360e76738381 100644 --- a/src/pages/NewChatConfirmPage.tsx +++ b/src/pages/NewChatConfirmPage.tsx @@ -55,7 +55,7 @@ function NewChatConfirmPage({newGroupDraft, allPersonalDetails}: NewChatConfirmP return []; } const options: Participant[] = newGroupDraft.participants.map((participant) => - OptionsListUtils.getParticipantsOption({accountID: participant.accountID, login: participant.login, reportID: ''}, allPersonalDetails), + OptionsListUtils.getParticipantsOption({accountID: participant.accountID, login: participant?.login, reportID: ''}, allPersonalDetails), ); return options; }, [allPersonalDetails, newGroupDraft?.participants]); @@ -92,7 +92,7 @@ function NewChatConfirmPage({newGroupDraft, allPersonalDetails}: NewChatConfirmP if (!newGroupDraft) { return; } - const newSelectedParticipants = (newGroupDraft.participants ?? []).filter((participant) => participant.login !== option.login); + const newSelectedParticipants = (newGroupDraft.participants ?? []).filter((participant) => participant?.login !== option.login); Report.setGroupDraft({participants: newSelectedParticipants}); }, [newGroupDraft], @@ -103,7 +103,7 @@ function NewChatConfirmPage({newGroupDraft, allPersonalDetails}: NewChatConfirmP return; } - const logins: string[] = (newGroupDraft.participants ?? []).map((participant) => participant.login); + const logins: string[] = (newGroupDraft.participants ?? []).map((participant) => participant.login).filter((login): login is string => !!login); Report.navigateToAndOpenReport(logins, true, undefined, newGroupDraft.reportName ?? '', newGroupDraft.avatarUri ?? '', avatarFile, optimisticReportID.current, true); }, [newGroupDraft, avatarFile]); diff --git a/src/pages/NewChatPage.tsx b/src/pages/NewChatPage.tsx index 65aa253ff09d..bcf0c917e80b 100755 --- a/src/pages/NewChatPage.tsx +++ b/src/pages/NewChatPage.tsx @@ -1,6 +1,7 @@ import isEmpty from 'lodash/isEmpty'; import reject from 'lodash/reject'; import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import {Keyboard} from 'react-native'; import {useOnyx} from 'react-native-onyx'; import Button from '@components/Button'; import ImportedStateIndicator from '@components/ImportedStateIndicator'; @@ -102,7 +103,7 @@ function useOptions() { let participantOption: OptionData | undefined | null = listOptions.personalDetails.find((option) => option.accountID === participant.accountID); if (!participantOption) { participantOption = OptionsListUtils.getUserToInviteOption({ - searchValue: participant.login, + searchValue: participant?.login, }); } if (!participantOption) { @@ -230,11 +231,13 @@ function NewChatPage() { if (isOptionInList) { newSelectedOptions = reject(selectedOptions, (selectedOption) => selectedOption.login === option.login); } else { - newSelectedOptions = [...selectedOptions, {...option, isSelected: true, selected: true, reportID: option.reportID ?? '-1'}]; + newSelectedOptions = [...selectedOptions, {...option, isSelected: true, selected: true, reportID: option.reportID ?? `${CONST.DEFAULT_NUMBER_ID}`}]; } selectionListRef?.current?.clearInputAfterSelect?.(); + selectionListRef.current?.focusTextInput(); + selectionListRef?.current?.scrollToIndex(Math.max(newSelectedOptions.length - 1, 0), true); setSelectedOptions(newSelectedOptions); } @@ -272,9 +275,13 @@ function NewChatPage() { if (!personalData || !personalData.login || !personalData.accountID) { return; } - const selectedParticipants: SelectedParticipant[] = selectedOptions.map((option: OptionData) => ({login: option.login ?? '', accountID: option.accountID ?? -1})); + const selectedParticipants: SelectedParticipant[] = selectedOptions.map((option: OptionData) => ({ + login: option?.login, + accountID: option.accountID ?? CONST.DEFAULT_NUMBER_ID, + })); const logins = [...selectedParticipants, {login: personalData.login, accountID: personalData.accountID}]; Report.setGroupDraft({participants: logins}); + Keyboard.dismiss(); Navigation.navigate(ROUTES.NEW_CHAT_CONFIRM); }, [selectedOptions, personalData]); const {isDismissed} = useDismissedReferralBanners({referralContentType: CONST.REFERRAL_PROGRAM.CONTENT_TYPES.START_CHAT}); diff --git a/src/pages/ReportDetailsPage.tsx b/src/pages/ReportDetailsPage.tsx index 166b12b27751..11f3f3c48efa 100644 --- a/src/pages/ReportDetailsPage.tsx +++ b/src/pages/ReportDetailsPage.tsx @@ -875,22 +875,15 @@ function ReportDetailsPage({policies, report, route, reportMetadata}: ReportDeta // Where to navigate back to after deleting the transaction and its report. const navigateToTargetUrl = useCallback(() => { - let urlToNavigateBack: string | undefined; - + // If transaction was not deleted (i.e. Cancel was clicked), do nothing + // which only dismiss the delete confirmation modal if (!isTransactionDeleted.current) { - if (caseID === CASES.DEFAULT) { - urlToNavigateBack = Task.getNavigationUrlOnTaskDelete(report); - if (urlToNavigateBack) { - Report.setDeleteTransactionNavigateBackUrl(urlToNavigateBack); - Navigation.goBack(urlToNavigateBack as Route); - } else { - Navigation.dismissModal(); - } - return; - } return; } + let urlToNavigateBack: string | undefined; + + // Only proceed with navigation logic if transaction was actually deleted if (!isEmptyObject(requestParentReportAction)) { const isTrackExpense = ReportActionsUtils.isTrackExpenseAction(requestParentReportAction); if (isTrackExpense) { @@ -906,7 +899,7 @@ function ReportDetailsPage({policies, report, route, reportMetadata}: ReportDeta Report.setDeleteTransactionNavigateBackUrl(urlToNavigateBack); ReportUtils.navigateBackOnDeleteTransaction(urlToNavigateBack as Route, true); } - }, [caseID, iouTransactionID, moneyRequestReport?.reportID, report, requestParentReportAction, isSingleTransactionView, isTransactionDeleted]); + }, [iouTransactionID, requestParentReportAction, isSingleTransactionView, isTransactionDeleted, moneyRequestReport?.reportID]); const mentionReportContextValue = useMemo(() => ({currentReportID: report.reportID, exactlyMatch: true}), [report.reportID]); diff --git a/src/pages/Search/AdvancedSearchFilters.tsx b/src/pages/Search/AdvancedSearchFilters.tsx index 10c8401b98aa..ccb4bbfe5da1 100644 --- a/src/pages/Search/AdvancedSearchFilters.tsx +++ b/src/pages/Search/AdvancedSearchFilters.tsx @@ -372,7 +372,7 @@ function AdvancedSearchFilters() { const [reports] = useOnyx(ONYXKEYS.COLLECTION.REPORT); const [savedSearches] = useOnyx(ONYXKEYS.SAVED_SEARCHES); const [searchAdvancedFilters = {} as SearchAdvancedFiltersForm] = useOnyx(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM); - const policyID = searchAdvancedFilters.policyID ?? '-1'; + const policyID = searchAdvancedFilters.policyID; const [cardList = {}] = useOnyx(ONYXKEYS.CARD_LIST); const taxRates = getAllTaxRates(); const personalDetails = usePersonalDetails(); @@ -434,8 +434,8 @@ function AdvancedSearchFilters() { const onSaveSearch = () => { const savedSearchKeys = Object.keys(savedSearches ?? {}); if (!queryJSON || (savedSearches && savedSearchKeys.includes(String(queryJSON.hash)))) { - // If the search is already saved, return early to prevent unnecessary API calls - Navigation.dismissModal(); + // If the search is already saved, we only display the results as we don't need to save it. + applyFiltersAndNavigate(); return; } diff --git a/src/pages/Search/EmptySearchView.tsx b/src/pages/Search/EmptySearchView.tsx index f5687c09bd8d..7d9550c5ec06 100644 --- a/src/pages/Search/EmptySearchView.tsx +++ b/src/pages/Search/EmptySearchView.tsx @@ -12,6 +12,7 @@ import MenuItem from '@components/MenuItem'; import SearchRowSkeleton from '@components/Skeletons/SearchRowSkeleton'; import Text from '@components/Text'; import TextLink from '@components/TextLink'; +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useEnvironment from '@hooks/useEnvironment'; import useLocalize from '@hooks/useLocalize'; import useStyleUtils from '@hooks/useStyleUtils'; @@ -116,6 +117,9 @@ function EmptySearchView({type, hasResults}: EmptySearchViewProps) { }); const viewTourTaskReportID = introSelected?.viewTour; const [viewTourTaskReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${viewTourTaskReportID}`); + const currentUserPersonalDetails = useCurrentUserPersonalDetails(); + const canModifyTask = Task.canModifyTask(viewTourTaskReport, currentUserPersonalDetails.accountID); + const canActionTask = Task.canActionTask(viewTourTaskReport, currentUserPersonalDetails.accountID); const content = useMemo(() => { switch (type) { @@ -149,7 +153,9 @@ function EmptySearchView({type, hasResults}: EmptySearchViewProps) { buttonAction: () => { Link.openExternalLink(navatticURL); Welcome.setSelfTourViewed(); - Task.completeTask(viewTourTaskReport); + if (viewTourTaskReport && canModifyTask && canActionTask) { + Task.completeTask(viewTourTaskReport); + } }, }, ] @@ -187,7 +193,9 @@ function EmptySearchView({type, hasResults}: EmptySearchViewProps) { buttonAction: () => { Link.openExternalLink(navatticURL); Welcome.setSelfTourViewed(); - Task.completeTask(viewTourTaskReport); + if (viewTourTaskReport && canModifyTask && canActionTask) { + Task.completeTask(viewTourTaskReport); + } }, }, ] @@ -235,6 +243,8 @@ function EmptySearchView({type, hasResults}: EmptySearchViewProps) { shouldRedirectToExpensifyClassic, hasResults, viewTourTaskReport, + canModifyTask, + canActionTask, ]); return ( diff --git a/src/pages/Search/SearchTypeMenu.tsx b/src/pages/Search/SearchTypeMenu.tsx index 110005c263f9..3fe047ab3187 100644 --- a/src/pages/Search/SearchTypeMenu.tsx +++ b/src/pages/Search/SearchTypeMenu.tsx @@ -21,7 +21,7 @@ import useSingleExecution from '@hooks/useSingleExecution'; import useThemeStyles from '@hooks/useThemeStyles'; import * as SearchActions from '@libs/actions/Search'; import Navigation from '@libs/Navigation/Navigation'; -import {getAllTaxRates, hasWorkspaceWithInvoices} from '@libs/PolicyUtils'; +import {canSendInvoice, getAllTaxRates} from '@libs/PolicyUtils'; import {hasInvoiceReports} from '@libs/ReportUtils'; import * as SearchQueryUtils from '@libs/SearchQueryUtils'; import * as SearchUIUtils from '@libs/SearchUIUtils'; @@ -71,7 +71,7 @@ function SearchTypeMenu({queryJSON, searchName}: SearchTypeMenuProps) { ); const {showDeleteModal, DeleteConfirmModal} = useDeleteSavedSearch(); const [session] = useOnyx(ONYXKEYS.SESSION); - + const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); const personalDetails = usePersonalDetails(); const [reports] = useOnyx(ONYXKEYS.COLLECTION.REPORT); const taxRates = getAllTaxRates(); @@ -97,7 +97,7 @@ function SearchTypeMenu({queryJSON, searchName}: SearchTypeMenuProps) { }, ]; - if (hasWorkspaceWithInvoices(session?.email) || hasInvoiceReports()) { + if (canSendInvoice(allPolicies, session?.email) || hasInvoiceReports()) { typeMenuItems.push({ title: translate('workspace.common.invoices'), type: CONST.SEARCH.DATA_TYPES.INVOICE, diff --git a/src/pages/Travel/TripDetailsPage.tsx b/src/pages/Travel/TripDetailsPage.tsx index d7a93e7cdeba..6236fb249001 100644 --- a/src/pages/Travel/TripDetailsPage.tsx +++ b/src/pages/Travel/TripDetailsPage.tsx @@ -48,8 +48,8 @@ function TripDetailsPage({route}: TripDetailsPageProps) { const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID); const [transaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${route.params.transactionID}`); - const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${transaction?.reportID ?? '-1'}`); - const [parentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${report?.parentReportID ?? '-1'}`); + const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${transaction?.reportID ?? CONST.DEFAULT_NUMBER_ID}`); + const [parentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${report?.parentReportID ?? CONST.DEFAULT_NUMBER_ID}`); const tripID = ReportUtils.getTripIDFromTransactionParentReportID(parentReport?.reportID); const reservationType = transaction?.receipt?.reservationList?.at(route.params.reservationIndex ?? 0)?.type; @@ -67,7 +67,7 @@ function TripDetailsPage({route}: TripDetailsPageProps) { > { if (!subtitle) { return false; @@ -142,7 +144,6 @@ function HeaderView({report, parentReportAction, reportID, onNavigationMenuButto const defaultSubscriptSize = ReportUtils.isExpenseRequest(report) ? CONST.AVATAR_SIZE.SMALL_NORMAL : CONST.AVATAR_SIZE.DEFAULT; const icons = ReportUtils.getIcons(reportHeaderData, personalDetails, null, '', -1, policy, invoiceReceiverPolicy); const brickRoadIndicator = ReportUtils.hasReportNameError(report) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : ''; - const shouldShowBorderBottom = !isTaskReport || !shouldUseNarrowLayout; const shouldDisableDetailPage = ReportUtils.shouldDisableDetailPage(report); const shouldUseGroupTitle = isGroupChat && (!!report?.reportName || !isMultipleParticipant); const isLoading = !report?.reportID || !title; @@ -154,7 +155,7 @@ function HeaderView({report, parentReportAction, reportID, onNavigationMenuButto return ( @@ -258,7 +259,7 @@ function HeaderView({report, parentReportAction, reportID, onNavigationMenuButto { if (ReportUtils.canEditPolicyDescription(policy)) { - Navigation.navigate(ROUTES.WORKSPACE_PROFILE_DESCRIPTION.getRoute(report.policyID ?? '-1')); + Navigation.navigate(ROUTES.WORKSPACE_PROFILE_DESCRIPTION.getRoute(report.policyID)); return; } Navigation.navigate(ROUTES.REPORT_WITH_ID_DETAILS.getRoute(reportID, Navigation.getReportRHPActiveRoute())); @@ -286,7 +287,7 @@ function HeaderView({report, parentReportAction, reportID, onNavigationMenuButto {!shouldUseNarrowLayout && isChatUsedForOnboarding && } - {isTaskReport && !shouldUseNarrowLayout && ReportUtils.isOpenTaskReport(report, parentReportAction) && } + {!shouldUseNarrowLayout && ReportUtils.isOpenTaskReport(report, parentReportAction) && } {!isParentReportLoading && canJoin && !shouldUseNarrowLayout && joinButton} {shouldDisplaySearchRouter && } @@ -315,6 +316,13 @@ function HeaderView({report, parentReportAction, reportID, onNavigationMenuButto addSpacing /> )} + {!!report && shouldUseNarrowLayout && ReportUtils.isOpenTaskReport(report, parentReportAction) && ( + + + + + + )} ); } diff --git a/src/pages/home/ReportScreen.tsx b/src/pages/home/ReportScreen.tsx index ef3137a8c7d2..b02ccec1a56b 100644 --- a/src/pages/home/ReportScreen.tsx +++ b/src/pages/home/ReportScreen.tsx @@ -15,7 +15,6 @@ import MoneyRequestHeader from '@components/MoneyRequestHeader'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import ReportActionsSkeletonView from '@components/ReportActionsSkeletonView'; import ScreenWrapper from '@components/ScreenWrapper'; -import TaskHeaderActionButton from '@components/TaskHeaderActionButton'; import useActiveWorkspace from '@hooks/useActiveWorkspace'; import useAppFocusEvent from '@hooks/useAppFocusEvent'; import type {CurrentReportIDContextValue} from '@hooks/useCurrentReportID'; @@ -777,15 +776,6 @@ function ReportScreen({route, navigation}: ReportScreenProps) { needsOffscreenAlphaCompositing > {headerView} - {!!report && ReportUtils.isTaskReport(report) && shouldUseNarrowLayout && ReportUtils.isOpenTaskReport(report, parentReportAction) && ( - - - - - - - - )} {!!accountManagerReportID && ReportUtils.isConciergeChatReport(report) && isBannerVisible && ( diff --git a/src/pages/home/report/comment/RenderCommentHTML.tsx b/src/pages/home/report/comment/RenderCommentHTML.tsx index e730ae061519..fc5679f8a1f1 100644 --- a/src/pages/home/report/comment/RenderCommentHTML.tsx +++ b/src/pages/home/report/comment/RenderCommentHTML.tsx @@ -5,10 +5,12 @@ import type {OriginalMessageSource} from '@src/types/onyx/OriginalMessage'; type RenderCommentHTMLProps = { source: OriginalMessageSource; html: string; + containsOnlyEmojis: boolean; }; -function RenderCommentHTML({html, source}: RenderCommentHTMLProps) { - const commentHtml = source === 'email' ? `${html}` : `${html}`; +function RenderCommentHTML({html, source, containsOnlyEmojis}: RenderCommentHTMLProps) { + const commentHtml = + source === 'email' ? `${html}` : `${html}`; return ; } diff --git a/src/pages/home/report/comment/TextCommentFragment.tsx b/src/pages/home/report/comment/TextCommentFragment.tsx index 5dc8c6a85b85..f8ea9b56871f 100644 --- a/src/pages/home/report/comment/TextCommentFragment.tsx +++ b/src/pages/home/report/comment/TextCommentFragment.tsx @@ -68,13 +68,12 @@ function TextCommentFragment({fragment, styleAsDeleted, styleAsMuted = false, so const containsOnlyEmojis = EmojiUtils.containsOnlyEmojis(text ?? ''); const containsEmojis = CONST.REGEX.ALL_EMOJIS.test(text ?? ''); if (!shouldRenderAsText(html, text ?? '') && !(containsOnlyEmojis && styleAsDeleted)) { - const editedTag = fragment?.isEdited ? `` : ''; + const editedTag = fragment?.isEdited ? `` : ''; const htmlWithDeletedTag = styleAsDeleted ? `${html}` : html; let htmlContent = htmlWithDeletedTag; if (containsOnlyEmojis) { htmlContent = Str.replaceAll(htmlContent, '', ''); - htmlContent = Str.replaceAll(htmlContent, '

', '
'); } else if (containsEmojis) { htmlContent = Str.replaceAll(htmlWithDeletedTag, '', ''); } @@ -87,6 +86,7 @@ function TextCommentFragment({fragment, styleAsDeleted, styleAsMuted = false, so return ( diff --git a/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.tsx b/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.tsx index 3464fab8e72a..393f396ece60 100644 --- a/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.tsx +++ b/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.tsx @@ -12,6 +12,7 @@ import * as Expensicons from '@components/Icon/Expensicons'; import type {PopoverMenuItem} from '@components/PopoverMenu'; import PopoverMenu from '@components/PopoverMenu'; import {useProductTrainingContext} from '@components/ProductTrainingContext'; +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useEnvironment from '@hooks/useEnvironment'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; @@ -447,6 +448,9 @@ function FloatingActionButtonAndPopover({onHideCreateMenu, onShowCreateMenu}: Fl const viewTourTaskReportID = introSelected?.viewTour; const [viewTourTaskReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${viewTourTaskReportID}`); + const currentUserPersonalDetails = useCurrentUserPersonalDetails(); + const canModifyTask = Task.canModifyTask(viewTourTaskReport, currentUserPersonalDetails.accountID); + const canActionTask = Task.canActionTask(viewTourTaskReport, currentUserPersonalDetails.accountID); return ( @@ -506,7 +510,7 @@ function FloatingActionButtonAndPopover({onHideCreateMenu, onShowCreateMenu}: Fl onSelected: () => { Link.openExternalLink(navatticURL); Welcome.setSelfTourViewed(Session.isAnonymousUser()); - if (viewTourTaskReport) { + if (viewTourTaskReport && canModifyTask && canActionTask) { Task.completeTask(viewTourTaskReport); } }, diff --git a/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx b/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx index 127885289fb1..990296d245fb 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx +++ b/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx @@ -74,10 +74,10 @@ function IOURequestStepScan({ const [startLocationPermissionFlow, setStartLocationPermissionFlow] = useState(false); const [fileResize, setFileResize] = useState(null); const [fileSource, setFileSource] = useState(''); - const [reportNameValuePairs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report?.reportID ?? -1}`); + const [reportNameValuePairs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report?.reportID ?? CONST.DEFAULT_NUMBER_ID}`); const policy = usePolicy(report?.policyID); const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST); - const [skipConfirmation] = useOnyx(`${ONYXKEYS.COLLECTION.SKIP_CONFIRMATION}${transactionID ?? -1}`); + const [skipConfirmation] = useOnyx(`${ONYXKEYS.COLLECTION.SKIP_CONFIRMATION}${transactionID ?? CONST.DEFAULT_NUMBER_ID}`); const platform = getPlatform(true); const [mutedPlatforms = {}] = useOnyx(ONYXKEYS.NVP_MUTED_PLATFORMS); const isPlatformMuted = mutedPlatforms[platform]; @@ -198,7 +198,10 @@ function IOURequestStepScan({ } if (!Str.isImage(file.name ?? '') && (file?.size ?? 0) > CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE) { - Alert.alert(translate('attachmentPicker.attachmentTooLarge'), translate('attachmentPicker.sizeExceeded')); + Alert.alert( + translate('attachmentPicker.attachmentTooLarge'), + translate('attachmentPicker.sizeExceededWithLimit', {maxUploadSizeInMB: CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE / (1024 * 1024)}), + ); return false; } @@ -295,7 +298,7 @@ function IOURequestStepScan({ // be added to the transaction (taken from the chat report participants) and then the person is taken to the confirmation step. const selectedParticipants = IOU.setMoneyRequestParticipantsFromReport(transactionID, report); const participants = selectedParticipants.map((participant) => { - const participantAccountID = participant?.accountID ?? -1; + const participantAccountID = participant?.accountID ?? CONST.DEFAULT_NUMBER_ID; return participantAccountID ? OptionsListUtils.getParticipantsOption(participant, personalDetails) : OptionsListUtils.getReportOption(participant); }); @@ -308,10 +311,10 @@ function IOURequestStepScan({ IOU.startSplitBill({ participants, currentUserLogin: currentUserPersonalDetails?.login ?? '', - currentUserAccountID: currentUserPersonalDetails?.accountID ?? -1, + currentUserAccountID: currentUserPersonalDetails.accountID, comment: '', receipt, - existingSplitChatReportID: reportID ?? -1, + existingSplitChatReportID: reportID, billable: false, category: '', tag: '', diff --git a/src/pages/iou/request/step/IOURequestStepScan/index.tsx b/src/pages/iou/request/step/IOURequestStepScan/index.tsx index 382da92fe94d..227076b9f4f3 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/index.tsx +++ b/src/pages/iou/request/step/IOURequestStepScan/index.tsx @@ -86,10 +86,10 @@ function IOURequestStepScan({ const [isQueriedPermissionState, setIsQueriedPermissionState] = useState(false); const getScreenshotTimeoutRef = useRef(null); - const [reportNameValuePairs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report?.reportID ?? -1}`); + const [reportNameValuePairs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report?.reportID ?? CONST.DEFAULT_NUMBER_ID}`); const policy = usePolicy(report?.policyID); const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST); - const [skipConfirmation] = useOnyx(`${ONYXKEYS.COLLECTION.SKIP_CONFIRMATION}${transactionID ?? -1}`); + const [skipConfirmation] = useOnyx(`${ONYXKEYS.COLLECTION.SKIP_CONFIRMATION}${transactionID ?? CONST.DEFAULT_NUMBER_ID}`); const [isLoadingReceipt, setIsLoadingReceipt] = useState(false); const [videoConstraints, setVideoConstraints] = useState(); @@ -294,7 +294,7 @@ function IOURequestStepScan({ // be added to the transaction (taken from the chat report participants) and then the person is taken to the confirmation step. const selectedParticipants = IOU.setMoneyRequestParticipantsFromReport(transactionID, report); const participants = selectedParticipants.map((participant) => { - const participantAccountID = participant?.accountID ?? -1; + const participantAccountID = participant?.accountID ?? CONST.DEFAULT_NUMBER_ID; return participantAccountID ? OptionsListUtils.getParticipantsOption(participant, personalDetails) : OptionsListUtils.getReportOption(participant); }); @@ -307,10 +307,10 @@ function IOURequestStepScan({ IOU.startSplitBill({ participants, currentUserLogin: currentUserPersonalDetails?.login ?? '', - currentUserAccountID: currentUserPersonalDetails?.accountID ?? -1, + currentUserAccountID: currentUserPersonalDetails.accountID, comment: '', receipt, - existingSplitChatReportID: reportID ?? -1, + existingSplitChatReportID: reportID, billable: false, category: '', tag: '', @@ -591,6 +591,16 @@ function IOURequestStepScan({ /> ) : null; + const getConfirmModalPrompt = () => { + if (!attachmentInvalidReason) { + return ''; + } + if (attachmentInvalidReason === 'attachmentPicker.sizeExceededWithLimit') { + return translate(attachmentInvalidReason, {maxUploadSizeInMB: CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE / (1024 * 1024)}); + } + return translate(attachmentInvalidReason); + }; + const mobileCameraView = () => ( <> @@ -767,7 +777,7 @@ function IOURequestStepScan({ onConfirm={hideRecieptModal} onCancel={hideRecieptModal} isVisible={isAttachmentInvalid} - prompt={attachmentInvalidReason ? translate(attachmentInvalidReason) : ''} + prompt={getConfirmModalPrompt()} confirmText={translate('common.close')} shouldShowCancelButton={false} /> diff --git a/src/pages/settings/Profile/PersonalDetails/PhoneNumberPage.tsx b/src/pages/settings/Profile/PersonalDetails/PhoneNumberPage.tsx index 1f2b80aa5f4e..15704221f259 100644 --- a/src/pages/settings/Profile/PersonalDetails/PhoneNumberPage.tsx +++ b/src/pages/settings/Profile/PersonalDetails/PhoneNumberPage.tsx @@ -56,7 +56,7 @@ function PhoneNumberPage() { errors[INPUT_IDS.PHONE_NUMBER] = translate('common.error.fieldRequired'); } const phoneNumberWithCountryCode = LoginUtils.appendCountryCode(values[INPUT_IDS.PHONE_NUMBER]); - const parsedPhoneNumber = PhoneNumberUtils.parsePhoneNumber(phoneNumberWithCountryCode); + const parsedPhoneNumber = PhoneNumberUtils.parsePhoneNumber(values[INPUT_IDS.PHONE_NUMBER]); if (!parsedPhoneNumber.possible || !Str.isValidE164Phone(phoneNumberWithCountryCode.slice(0))) { errors[INPUT_IDS.PHONE_NUMBER] = translate('bankAccount.error.phoneNumber'); } diff --git a/src/pages/settings/Subscription/SubscriptionPlan.tsx b/src/pages/settings/Subscription/SubscriptionPlan.tsx deleted file mode 100644 index 33933027dd45..000000000000 --- a/src/pages/settings/Subscription/SubscriptionPlan.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import React from 'react'; -import {View} from 'react-native'; -import {useOnyx} from 'react-native-onyx'; -import Icon from '@components/Icon'; -import * as Expensicons from '@components/Icon/Expensicons'; -import * as Illustrations from '@components/Icon/Illustrations'; -import Section from '@components/Section'; -import Text from '@components/Text'; -import useLocalize from '@hooks/useLocalize'; -import usePreferredCurrency from '@hooks/usePreferredCurrency'; -import useSubscriptionPlan from '@hooks/useSubscriptionPlan'; -import useSubscriptionPrice from '@hooks/useSubscriptionPrice'; -import useTheme from '@hooks/useTheme'; -import useThemeStyles from '@hooks/useThemeStyles'; -import {convertToShortDisplayString} from '@libs/CurrencyUtils'; -import variables from '@styles/variables'; -import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; -import SaveWithExpensifyButton from './SaveWithExpensifyButton'; - -function SubscriptionPlan() { - const {translate} = useLocalize(); - const styles = useThemeStyles(); - const theme = useTheme(); - - const [privateSubscription] = useOnyx(ONYXKEYS.NVP_PRIVATE_SUBSCRIPTION); - - const subscriptionPlan = useSubscriptionPlan(); - const subscriptionPrice = useSubscriptionPrice(); - const preferredCurrency = usePreferredCurrency(); - - const isCollect = subscriptionPlan === CONST.POLICY.TYPE.TEAM; - const isAnnual = privateSubscription?.type === CONST.SUBSCRIPTION.TYPE.ANNUAL; - - const benefitsList = isCollect - ? [ - translate('subscription.yourPlan.collect.benefit1'), - translate('subscription.yourPlan.collect.benefit2'), - translate('subscription.yourPlan.collect.benefit3'), - translate('subscription.yourPlan.collect.benefit4'), - translate('subscription.yourPlan.collect.benefit5'), - translate('subscription.yourPlan.collect.benefit6'), - translate('subscription.yourPlan.collect.benefit7'), - ] - : [ - translate('subscription.yourPlan.control.benefit1'), - translate('subscription.yourPlan.control.benefit2'), - translate('subscription.yourPlan.control.benefit3'), - translate('subscription.yourPlan.control.benefit4'), - translate('subscription.yourPlan.control.benefit5'), - translate('subscription.yourPlan.control.benefit6'), - ]; - - return ( -
- - - {translate(`subscription.yourPlan.${isCollect ? 'collect' : 'control'}.title`)} - - {translate(`subscription.yourPlan.${isCollect ? 'collect' : 'control'}.${isAnnual ? 'priceAnnual' : 'pricePayPerUse'}`, { - lower: convertToShortDisplayString(subscriptionPrice, preferredCurrency), - upper: convertToShortDisplayString(subscriptionPrice * CONST.SUBSCRIPTION_PRICE_FACTOR, preferredCurrency), - })} - - {benefitsList.map((benefit) => ( - - - {benefit} - - ))} - - - - - {translate('subscription.yourPlan.saveWithExpensifyTitle')} - {translate('subscription.yourPlan.saveWithExpensifyDescription')} - - - -
- ); -} - -SubscriptionPlan.displayName = 'SubscriptionPlan'; - -export default SubscriptionPlan; diff --git a/src/pages/settings/Subscription/SaveWithExpensifyButton/index.native.tsx b/src/pages/settings/Subscription/SubscriptionPlan/SaveWithExpensifyButton/index.native.tsx similarity index 100% rename from src/pages/settings/Subscription/SaveWithExpensifyButton/index.native.tsx rename to src/pages/settings/Subscription/SubscriptionPlan/SaveWithExpensifyButton/index.native.tsx diff --git a/src/pages/settings/Subscription/SaveWithExpensifyButton/index.tsx b/src/pages/settings/Subscription/SubscriptionPlan/SaveWithExpensifyButton/index.tsx similarity index 100% rename from src/pages/settings/Subscription/SaveWithExpensifyButton/index.tsx rename to src/pages/settings/Subscription/SubscriptionPlan/SaveWithExpensifyButton/index.tsx diff --git a/src/pages/settings/Subscription/SubscriptionPlan/SubscriptionPlanCard.tsx b/src/pages/settings/Subscription/SubscriptionPlan/SubscriptionPlanCard.tsx new file mode 100644 index 000000000000..33d3f40a0b36 --- /dev/null +++ b/src/pages/settings/Subscription/SubscriptionPlan/SubscriptionPlanCard.tsx @@ -0,0 +1,78 @@ +import React from 'react'; +import {View} from 'react-native'; +import type {SvgProps} from 'react-native-svg'; +import type {ValueOf} from 'type-fest'; +import Icon from '@components/Icon'; +import * as Expensicons from '@components/Icon/Expensicons'; +import {PressableWithFeedback} from '@components/Pressable'; +import SelectCircle from '@components/SelectCircle'; +import Text from '@components/Text'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; +import variables from '@styles/variables'; +import type CONST from '@src/CONST'; + +type PersonalPolicyTypeExludedProps = Exclude, 'personal'>; + +type SubscriptionPlanCardProps = { + index: number; + plan: { + title: string; + src: React.FC; + benefits: string[]; + description: string; + isSelected: boolean; + type: PersonalPolicyTypeExludedProps; + }; + + onPress: (type: PersonalPolicyTypeExludedProps) => void; +}; +function SubscriptionPlanCard({plan, index, onPress}: SubscriptionPlanCardProps) { + const styles = useThemeStyles(); + const theme = useTheme(); + + return ( + + onPress(plan.type)} + > + + + + + + + {plan.title} + {plan.description} + {plan.benefits.map((benefit) => ( + + + {benefit} + + ))} + + + ); +} + +SubscriptionPlanCard.displayName = 'SubscriptionPlanCard'; + +export default SubscriptionPlanCard; +export type {PersonalPolicyTypeExludedProps}; diff --git a/src/pages/settings/Subscription/SubscriptionPlan/index.tsx b/src/pages/settings/Subscription/SubscriptionPlan/index.tsx new file mode 100644 index 000000000000..da8caab785f1 --- /dev/null +++ b/src/pages/settings/Subscription/SubscriptionPlan/index.tsx @@ -0,0 +1,151 @@ +import React, {useMemo} from 'react'; +import {View} from 'react-native'; +import {useOnyx} from 'react-native-onyx'; +import Icon from '@components/Icon'; +import * as Illustrations from '@components/Icon/Illustrations'; +import Section from '@components/Section'; +import Text from '@components/Text'; +import useLocalize from '@hooks/useLocalize'; +import usePreferredCurrency from '@hooks/usePreferredCurrency'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useSubscriptionPlan from '@hooks/useSubscriptionPlan'; +import useThemeStyles from '@hooks/useThemeStyles'; +import {getCurrentUserAccountID} from '@libs/actions/Report'; +import {convertToShortDisplayString} from '@libs/CurrencyUtils'; +import {getOwnedPaidPolicies} from '@libs/PolicyUtils'; +import Navigation from '@navigation/Navigation'; +import variables from '@styles/variables'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import SaveWithExpensifyButton from './SaveWithExpensifyButton'; +import SubscriptionPlanCard from './SubscriptionPlanCard'; +import type {PersonalPolicyTypeExludedProps} from './SubscriptionPlanCard'; + +function SubscriptionPlan() { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + const currentUserAccountID = getCurrentUserAccountID(); + const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); + const [privateSubscription] = useOnyx(ONYXKEYS.NVP_PRIVATE_SUBSCRIPTION); + const subscriptionPlan = useSubscriptionPlan(); + const ownerPolicies = useMemo(() => getOwnedPaidPolicies(policies, currentUserAccountID), [policies, currentUserAccountID]); + const preferredCurrency = usePreferredCurrency(); + const {shouldUseNarrowLayout} = useResponsiveLayout(); + + const isAnnual = privateSubscription?.type === CONST.SUBSCRIPTION.TYPE.ANNUAL; + + function getSubscriptionPrice(plan: PersonalPolicyTypeExludedProps): number { + if (!privateSubscription?.type) { + return 0; + } + + return CONST.SUBSCRIPTION_PRICES[preferredCurrency][plan][privateSubscription.type]; + } + + const plans = [ + { + type: CONST.POLICY.TYPE.TEAM, + title: translate('subscription.yourPlan.collect.title'), + benefits: [ + translate('subscription.yourPlan.collect.benefit1'), + translate('subscription.yourPlan.collect.benefit2'), + translate('subscription.yourPlan.collect.benefit3'), + translate('subscription.yourPlan.collect.benefit4'), + translate('subscription.yourPlan.collect.benefit5'), + translate('subscription.yourPlan.collect.benefit6'), + translate('subscription.yourPlan.collect.benefit7'), + ], + src: Illustrations.Mailbox, + description: translate(`subscription.yourPlan.collect.${isAnnual ? 'priceAnnual' : 'pricePayPerUse'}`, { + lower: convertToShortDisplayString(getSubscriptionPrice(CONST.POLICY.TYPE.TEAM), preferredCurrency), + upper: convertToShortDisplayString(getSubscriptionPrice(CONST.POLICY.TYPE.TEAM) * CONST.SUBSCRIPTION_PRICE_FACTOR, preferredCurrency), + }), + isSelected: subscriptionPlan === CONST.POLICY.TYPE.TEAM, + }, + { + type: CONST.POLICY.TYPE.CORPORATE, + title: translate('subscription.yourPlan.control.title'), + benefits: [ + translate('subscription.yourPlan.control.benefit1'), + translate('subscription.yourPlan.control.benefit2'), + translate('subscription.yourPlan.control.benefit3'), + translate('subscription.yourPlan.control.benefit4'), + translate('subscription.yourPlan.control.benefit5'), + translate('subscription.yourPlan.control.benefit6'), + ], + src: Illustrations.ShieldYellow, + description: translate(`subscription.yourPlan.control.${isAnnual ? 'priceAnnual' : 'pricePayPerUse'}`, { + lower: convertToShortDisplayString(getSubscriptionPrice(CONST.POLICY.TYPE.CORPORATE), preferredCurrency), + upper: convertToShortDisplayString(getSubscriptionPrice(CONST.POLICY.TYPE.CORPORATE) * CONST.SUBSCRIPTION_PRICE_FACTOR, preferredCurrency), + }), + isSelected: subscriptionPlan === CONST.POLICY.TYPE.CORPORATE, + }, + ]; + + const handlePlanPress = (planType: PersonalPolicyTypeExludedProps) => { + // If the selected plan and the current plan are the same, and the user has no policies, return. + if (planType === subscriptionPlan || !ownerPolicies.length) { + return; + } + + // If the user has one policy as owner and selected plan is team, navigate to downgrade page. + if (ownerPolicies.length === 1 && planType === CONST.POLICY.TYPE.TEAM) { + Navigation.navigate(ROUTES.WORKSPACE_DOWNGRADE.getRoute(ownerPolicies.at(0)?.id)); + return; + } + + // If the user has one policy as owner and selected plan is corporate, navigate to upgrade page. + if (ownerPolicies.length === 1 && planType === CONST.POLICY.TYPE.CORPORATE) { + Navigation.navigate(ROUTES.WORKSPACE_UPGRADE.getRoute(ownerPolicies.at(0)?.id)); + return; + } + + // If the user has multiple policies as owner and selected plan is team, navigate to downgrade page. + if (ownerPolicies.length > 1 && planType === CONST.POLICY.TYPE.TEAM) { + Navigation.navigate(ROUTES.WORKSPACE_DOWNGRADE.getRoute()); + return; + } + + // If the user has multiple policies as owner and selected plan is corporate, navigate to upgrade page. + if (ownerPolicies.length > 1 && planType === CONST.POLICY.TYPE.CORPORATE) { + Navigation.navigate(ROUTES.WORKSPACE_UPGRADE.getRoute()); + } + }; + + return ( +
+ + {plans.map((plan, index) => ( + + ))} + + + + + {translate('subscription.yourPlan.saveWithExpensifyTitle')} + {translate('subscription.yourPlan.saveWithExpensifyDescription')} + + + +
+ ); +} + +SubscriptionPlan.displayName = 'SubscriptionPlan'; + +export default SubscriptionPlan; diff --git a/src/pages/workspace/WorkspaceProfilePlanTypePage.tsx b/src/pages/workspace/WorkspaceProfilePlanTypePage.tsx index 253f64d1574f..c271aa4d518b 100644 --- a/src/pages/workspace/WorkspaceProfilePlanTypePage.tsx +++ b/src/pages/workspace/WorkspaceProfilePlanTypePage.tsx @@ -20,6 +20,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import OpenWorkspacePlanPage from '@libs/actions/Policy/Plan'; import Navigation from '@navigation/Navigation'; import CardSectionUtils from '@pages/settings/Subscription/CardSection/utils'; +import type {PersonalPolicyTypeExludedProps} from '@pages/settings/Subscription/SubscriptionPlan/SubscriptionPlanCard'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; @@ -57,8 +58,8 @@ function WorkspaceProfilePlanTypePage({policy}: WithPolicyProps) { .filter((type) => type !== CONST.POLICY.TYPE.PERSONAL) .map((policyType) => ({ value: policyType, - text: translate(`workspace.planTypePage.planTypes.${policyType as Exclude}.label`), - alternateText: translate(`workspace.planTypePage.planTypes.${policyType as Exclude}.description`), + text: translate(`workspace.planTypePage.planTypes.${policyType as PersonalPolicyTypeExludedProps}.label`), + alternateText: translate(`workspace.planTypePage.planTypes.${policyType as PersonalPolicyTypeExludedProps}.description`), keyForList: policyType, isSelected: policyType === currentPlan, })) diff --git a/src/pages/workspace/categories/CategoryRequireReceiptsOverPage.tsx b/src/pages/workspace/categories/CategoryRequireReceiptsOverPage.tsx index b60401129635..119786e3b28e 100644 --- a/src/pages/workspace/categories/CategoryRequireReceiptsOverPage.tsx +++ b/src/pages/workspace/categories/CategoryRequireReceiptsOverPage.tsx @@ -45,7 +45,7 @@ function CategoryRequireReceiptsOverPage({ const isAlwaysSelected = policyCategories?.[categoryName]?.maxAmountNoReceipt === 0; const isNeverSelected = policyCategories?.[categoryName]?.maxAmountNoReceipt === CONST.DISABLED_MAX_EXPENSE_VALUE; - const maxExpenseAmountToDisplay = policy?.maxExpenseAmount === CONST.DISABLED_MAX_EXPENSE_VALUE ? 0 : policy?.maxExpenseAmount; + const maxExpenseAmountToDisplay = policy?.maxExpenseAmountNoReceipt === CONST.DISABLED_MAX_EXPENSE_VALUE ? 0 : policy?.maxExpenseAmountNoReceipt; const requireReceiptsOverListData = [ { diff --git a/src/pages/workspace/companyCards/addNew/SelectBankStep.tsx b/src/pages/workspace/companyCards/addNew/SelectBankStep.tsx index 2c6745fabe14..15ead5b9a323 100644 --- a/src/pages/workspace/companyCards/addNew/SelectBankStep.tsx +++ b/src/pages/workspace/companyCards/addNew/SelectBankStep.tsx @@ -94,6 +94,7 @@ function SelectBankStep() { showConfirmButton confirmButtonText={translate('common.next')} onConfirm={submit} + confirmButtonStyles={styles.mt5} > {hasError && ( diff --git a/src/pages/workspace/downgrade/DowngradeIntro.tsx b/src/pages/workspace/downgrade/DowngradeIntro.tsx index ba8c91550561..d226576d84cb 100644 --- a/src/pages/workspace/downgrade/DowngradeIntro.tsx +++ b/src/pages/workspace/downgrade/DowngradeIntro.tsx @@ -10,15 +10,18 @@ import useLocalize from '@hooks/useLocalize'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; import {openLink} from '@libs/actions/Link'; +import Navigation from '@libs/Navigation/Navigation'; import CONST from '@src/CONST'; +import ROUTES from '@src/ROUTES'; type Props = { buttonDisabled?: boolean; loading?: boolean; onDowngrade: () => void; + policyID?: string; }; -function DowngradeIntro({onDowngrade, buttonDisabled, loading}: Props) { +function DowngradeIntro({onDowngrade, buttonDisabled, loading, policyID}: Props) { const styles = useThemeStyles(); const {translate} = useLocalize(); const {environmentURL} = useEnvironment(); @@ -67,14 +70,23 @@ function DowngradeIntro({onDowngrade, buttonDisabled, loading}: Props) { {translate('workspace.downgrade.commonFeatures.benefits.warning')} -