diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index c1238d6805aa..459a780ca8b4 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -51,7 +51,10 @@ For example: 1. Click on the text input to bring it into focus 2. Upload an image via copy paste 3. Verify a modal appears displaying a preview of that image + +It's acceptable to write "Same as tests" if the QA team is able to run the tests in the above "Tests" section. ---> +// TODO: These must be filled out, or the issue title must include "[No QA]." - [ ] Verify that no errors appear in the JS console diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 59f41bd12526..415d7b36c4cb 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -182,6 +182,7 @@ jobs: submodules: true path: 'Mobile-Expensify' token: ${{ secrets.OS_BOTIFY_TOKEN }} + # fetch-depth: 0 is required in order to fetch the correct submodule branch fetch-depth: 0 - name: Update submodule @@ -276,6 +277,13 @@ jobs: name: android-hybrid-build-artifact path: ${{ env.aabPath }} + - name: Upload Android sourcemap artifact + if: ${{ !fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} + uses: actions/upload-artifact@v4 + with: + name: android-hybrid-sourcemap-artifact + path: /home/runner/work/App/App/Mobile-Expensify/Android/build/generated/sourcemaps/react/release/index.android.bundle.map + - name: Set current App version in Env run: echo "VERSION=$(npm run print-version --silent)" >> "$GITHUB_ENV" @@ -482,6 +490,7 @@ jobs: submodules: true path: 'Mobile-Expensify' token: ${{ secrets.OS_BOTIFY_TOKEN }} + # fetch-depth: 0 is required in order to fetch the correct submodule branch fetch-depth: 0 - name: Update submodule @@ -597,6 +606,13 @@ jobs: name: ios-hybrid-build-artifact path: ${{ env.ipaPath }} + - name: Upload iOS sourcemap artifact + if: ${{ !fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} + uses: actions/upload-artifact@v4 + with: + name: ios-hybrid-sourcemap-artifact + path: /Users/runner/work/App/App/Mobile-Expensify/main.jsbundle.map + - name: Warn deployers if iOS production deploy failed if: ${{ failure() && fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} uses: 8398a7/action-slack@v3 @@ -802,19 +818,25 @@ 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-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" "./ios-sourcemaps-artifact/main.jsbundle.map#ios-sourcemap.js.map" "./ios-build-artifact/New Expensify.ipa#ios.ipa" "./ios-hybrid-build-artifact/Expensify.ipa#ios-hybrid.ipa" + "./ios-hybrid-sourcemap-artifact/main.jsbundle.map#ios-hybrid-sourcemap.js.map" "./web-staging-sourcemaps-artifact/web-staging-sourcemap.js.map#web-staging-sourcemap.js.map" "./web-staging-build-tar-gz-artifact/webBuild.tar.gz#web-staging.tar.gz" "./web-staging-build-zip-artifact/webBuild.zip#web-staging.zip" ) - + # Loop through each file and upload individually (so if one fails, we still have other platforms uploaded) for file_entry in "${files[@]}"; do - gh release upload ${{ needs.prep.outputs.APP_VERSION }} --repo ${{ github.repository }} --clobber "$file_entry" + gh release upload ${{ needs.prep.outputs.APP_VERSION }} --repo ${{ github.repository }} --clobber "$file_entry" || { + echo "Failed to upload $file_entry. Continuing with the next file." + continue + } + echo "Successfully uploaded $file_entry." done env: GITHUB_TOKEN: ${{ github.token }} @@ -870,10 +892,14 @@ jobs: "./web-build-tar-gz-artifact/webBuild.tar.gz#web-production.tar.gz" "./web-build-zip-artifact/webBuild.zip#web-production.zip" ) - + # Loop through each file and upload individually (so if one fails, we still have other platforms uploaded) for file_entry in "${files[@]}"; do - gh release upload ${{ needs.prep.outputs.APP_VERSION }} --repo ${{ github.repository }} --clobber "$file_entry" + gh release upload ${{ needs.prep.outputs.APP_VERSION }} --repo ${{ github.repository }} --clobber "$file_entry" || { + echo "Failed to upload $file_entry. Continuing with the next file." + continue + } + echo "Successfully uploaded $file_entry." done env: GITHUB_TOKEN: ${{ github.token }} diff --git a/.github/workflows/deployNewHelp.yml b/.github/workflows/deployNewHelp.yml index 2d2f551482d2..8e455979a50e 100644 --- a/.github/workflows/deployNewHelp.yml +++ b/.github/workflows/deployNewHelp.yml @@ -53,7 +53,7 @@ jobs: # Install Node for _scripts/*.js - name: Set up Node.js - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: '20.18.0' diff --git a/Gemfile.lock b/Gemfile.lock index 35920fc3e988..71f4fd64bc0c 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -20,20 +20,20 @@ GEM artifactory (3.0.17) atomos (0.1.3) aws-eventstream (1.3.0) - aws-partitions (1.979.0) - aws-sdk-core (3.209.1) + aws-partitions (1.1001.0) + aws-sdk-core (3.211.0) aws-eventstream (~> 1, >= 1.3.0) - aws-partitions (~> 1, >= 1.651.0) + aws-partitions (~> 1, >= 1.992.0) aws-sigv4 (~> 1.9) jmespath (~> 1, >= 1.6.1) - aws-sdk-kms (1.94.0) - aws-sdk-core (~> 3, >= 3.207.0) + aws-sdk-kms (1.95.0) + aws-sdk-core (~> 3, >= 3.210.0) aws-sigv4 (~> 1.5) - aws-sdk-s3 (1.166.0) - aws-sdk-core (~> 3, >= 3.207.0) + aws-sdk-s3 (1.169.0) + aws-sdk-core (~> 3, >= 3.210.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.5) - aws-sigv4 (1.10.0) + aws-sigv4 (1.10.1) aws-eventstream (~> 1, >= 1.0.2) babosa (1.0.4) base64 (0.2.0) @@ -89,7 +89,7 @@ GEM escape (0.0.4) ethon (0.16.0) ffi (>= 1.15.0) - excon (0.111.0) + excon (0.112.0) faraday (1.10.4) faraday-em_http (~> 1.0) faraday-em_synchrony (~> 1.0) @@ -119,7 +119,7 @@ GEM faraday_middleware (1.2.1) faraday (~> 1.0) fastimage (2.3.1) - fastlane (2.222.0) + fastlane (2.225.0) CFPropertyList (>= 2.3, < 4.0.0) addressable (>= 2.8, < 3.0.0) artifactory (~> 3.0) @@ -135,6 +135,7 @@ GEM faraday-cookie_jar (~> 0.0.6) faraday_middleware (~> 1.0) fastimage (>= 2.1.0, < 3.0.0) + fastlane-sirp (>= 1.0.0) gh_inspector (>= 1.1.2, < 2.0.0) google-apis-androidpublisher_v3 (~> 0.3) google-apis-playcustomapp_v1 (~> 0.1) @@ -164,6 +165,8 @@ GEM apktools (~> 0.7) aws-sdk-s3 (~> 1) mime-types (~> 3.3) + fastlane-sirp (1.0.0) + sysrandom (~> 1.0) ffi (1.17.0) ffi (1.17.0-arm64-darwin) ffi (1.17.0-x86_64-darwin) @@ -214,8 +217,8 @@ GEM i18n (1.14.5) concurrent-ruby (~> 1.0) jmespath (1.6.2) - json (2.7.2) - jwt (2.9.1) + json (2.7.6) + jwt (2.9.3) base64 mime-types (3.5.1) mime-types-data (~> 3.2015) @@ -226,7 +229,7 @@ GEM molinillo (0.8.0) multi_json (1.15.0) multipart-post (2.4.1) - nanaimo (0.3.0) + nanaimo (0.4.0) nap (1.1.0) naturally (2.2.1) netrc (0.11.0) @@ -241,7 +244,7 @@ GEM trailblazer-option (>= 0.1.1, < 0.2.0) uber (< 0.2.0) retriable (3.1.2) - rexml (3.3.7) + rexml (3.3.9) rouge (2.0.7) ruby-macho (2.5.1) ruby2_keywords (0.0.5) @@ -255,6 +258,7 @@ GEM simctl (1.6.10) CFPropertyList naturally + sysrandom (1.0.5) terminal-notifier (2.0.0) terminal-table (3.0.2) unicode-display_width (>= 1.1.1, < 3) @@ -270,13 +274,13 @@ GEM uber (0.1.0) unicode-display_width (2.6.0) word_wrap (1.0.0) - xcodeproj (1.25.0) + xcodeproj (1.27.0) CFPropertyList (>= 2.3.3, < 4.0) atomos (~> 0.1.3) claide (>= 1.0.2, < 2.0) colored2 (~> 3.1) - nanaimo (~> 0.3.0) - rexml (>= 3.3.2, < 4.0) + nanaimo (~> 0.4.0) + rexml (>= 3.3.6, < 4.0) xcpretty (0.3.0) rouge (~> 2.0.7) xcpretty-travis-formatter (1.0.1) diff --git a/android/app/build.gradle b/android/app/build.gradle index e0876839e4bd..f7869e907b2e 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 1009005607 - versionName "9.0.56-7" + versionCode 1009005708 + versionName "9.0.57-8" // 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/assets/images/binoculars.svg b/assets/images/binoculars.svg new file mode 100644 index 000000000000..64977dee38b5 --- /dev/null +++ b/assets/images/binoculars.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/desktop/package-lock.json b/desktop/package-lock.json index 75cb080f1349..926fb1e24d22 100644 --- a/desktop/package-lock.json +++ b/desktop/package-lock.json @@ -9,7 +9,7 @@ "dependencies": { "electron-context-menu": "^2.3.0", "electron-log": "^4.4.8", - "electron-updater": "^6.3.8", + "electron-updater": "^6.3.9", "mime-types": "^2.1.35", "node-machine-id": "^1.1.12" }, @@ -59,9 +59,9 @@ } }, "node_modules/builder-util-runtime": { - "version": "9.2.9", - "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.2.9.tgz", - "integrity": "sha512-DWeHdrRFVvNnVyD4+vMztRpXegOGaQHodsAjyhstTbUNBIjebxM1ahxokQL+T1v8vpW8SY7aJ5is/zILH82lAw==", + "version": "9.2.10", + "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.2.10.tgz", + "integrity": "sha512-6p/gfG1RJSQeIbz8TK5aPNkoztgY1q5TgmGFMAXcY8itsGW6Y2ld1ALsZ5UJn8rog7hKF3zHx5iQbNQ8uLcRlw==", "license": "MIT", "dependencies": { "debug": "^4.3.4", @@ -156,12 +156,12 @@ "integrity": "sha512-QQ4GvrXO+HkgqqEOYbi+DHL7hj5JM+nHi/j+qrN9zeeXVKy8ZABgbu4CnG+BBqDZ2+tbeq9tUC4DZfIWFU5AZA==" }, "node_modules/electron-updater": { - "version": "6.3.8", - "resolved": "https://registry.npmjs.org/electron-updater/-/electron-updater-6.3.8.tgz", - "integrity": "sha512-OFsA2vyuOZgsqq4EW6tgW8X8e521ybDmQyIYLqss7HdXev+Ak90YatzpIECOBJXpmro5YDq4yZ2xFsKXqPt1DQ==", + "version": "6.3.9", + "resolved": "https://registry.npmjs.org/electron-updater/-/electron-updater-6.3.9.tgz", + "integrity": "sha512-2PJNONi+iBidkoC5D1nzT9XqsE8Q1X28Fn6xRQhO3YX8qRRyJ3mkV4F1aQsuRnYPqq6Hw+E51y27W75WgDoofw==", "license": "MIT", "dependencies": { - "builder-util-runtime": "9.2.9", + "builder-util-runtime": "9.2.10", "fs-extra": "^10.1.0", "js-yaml": "^4.1.0", "lazy-val": "^1.0.5", @@ -469,9 +469,9 @@ "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==" }, "builder-util-runtime": { - "version": "9.2.9", - "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.2.9.tgz", - "integrity": "sha512-DWeHdrRFVvNnVyD4+vMztRpXegOGaQHodsAjyhstTbUNBIjebxM1ahxokQL+T1v8vpW8SY7aJ5is/zILH82lAw==", + "version": "9.2.10", + "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.2.10.tgz", + "integrity": "sha512-6p/gfG1RJSQeIbz8TK5aPNkoztgY1q5TgmGFMAXcY8itsGW6Y2ld1ALsZ5UJn8rog7hKF3zHx5iQbNQ8uLcRlw==", "requires": { "debug": "^4.3.4", "sax": "^1.2.4" @@ -538,11 +538,11 @@ "integrity": "sha512-QQ4GvrXO+HkgqqEOYbi+DHL7hj5JM+nHi/j+qrN9zeeXVKy8ZABgbu4CnG+BBqDZ2+tbeq9tUC4DZfIWFU5AZA==" }, "electron-updater": { - "version": "6.3.8", - "resolved": "https://registry.npmjs.org/electron-updater/-/electron-updater-6.3.8.tgz", - "integrity": "sha512-OFsA2vyuOZgsqq4EW6tgW8X8e521ybDmQyIYLqss7HdXev+Ak90YatzpIECOBJXpmro5YDq4yZ2xFsKXqPt1DQ==", + "version": "6.3.9", + "resolved": "https://registry.npmjs.org/electron-updater/-/electron-updater-6.3.9.tgz", + "integrity": "sha512-2PJNONi+iBidkoC5D1nzT9XqsE8Q1X28Fn6xRQhO3YX8qRRyJ3mkV4F1aQsuRnYPqq6Hw+E51y27W75WgDoofw==", "requires": { - "builder-util-runtime": "9.2.9", + "builder-util-runtime": "9.2.10", "fs-extra": "^10.1.0", "js-yaml": "^4.1.0", "lazy-val": "^1.0.5", diff --git a/desktop/package.json b/desktop/package.json index 326d6f24f740..ac66df7e9aed 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -6,7 +6,7 @@ "dependencies": { "electron-context-menu": "^2.3.0", "electron-log": "^4.4.8", - "electron-updater": "^6.3.8", + "electron-updater": "^6.3.9", "mime-types": "^2.1.35", "node-machine-id": "^1.1.12" }, diff --git a/docs/articles/expensify-classic/bank-accounts-and-payments/payments/Reimburse-Reports.md b/docs/articles/expensify-classic/bank-accounts-and-payments/payments/Reimburse-Reports.md index afe366fb1dbe..41dc52a4239c 100644 --- a/docs/articles/expensify-classic/bank-accounts-and-payments/payments/Reimburse-Reports.md +++ b/docs/articles/expensify-classic/bank-accounts-and-payments/payments/Reimburse-Reports.md @@ -14,7 +14,7 @@ Before a report can be reimbursed via direct deposit: To reimburse a report via direct deposit (USD): 1. Open the report. -2. Click the **Reimburse** button and select **Via Direct Deposit (ACH)**. +2. Click the **Reimburse** button and select **Via Direct Deposit**. 3. Confirm that the correct bank account is listed in the dropdown menu. 4. Click **Accept Terms & Pay**. @@ -27,7 +27,7 @@ Before a report can be reimbursed via global reimbursement: To reimburse a report via global reimbursement: 1. Open the report. -2. Click the **Reimburse** button and select **Via Direct Deposit (ACH)**. +2. Click the **Reimburse** button and select **Via Direct Deposit**. 3. Confirm that the correct bank account is listed in the dropdown menu. 4. Click **Accept Terms & Pay**. diff --git a/docs/articles/expensify-classic/connections/quickbooks-desktop/Connect-To-QuickBooks-Desktop.md b/docs/articles/expensify-classic/connections/quickbooks-desktop/Connect-To-QuickBooks-Desktop.md index bda84eb0a49f..30785330a9ad 100644 --- a/docs/articles/expensify-classic/connections/quickbooks-desktop/Connect-To-QuickBooks-Desktop.md +++ b/docs/articles/expensify-classic/connections/quickbooks-desktop/Connect-To-QuickBooks-Desktop.md @@ -52,6 +52,10 @@ For this step, it is key to ensure that the correct company file is open in Quic ![The Web Connector pop-up, where you will need to click "Yes"](https://help.expensify.com/assets/images/QBO_desktop_07.png){:width="100%"} +{% include info.html %} +Be sure to securely save this password in a trusted password manager. You'll need it for future configuration updates or troubleshooting. Having it easily accessible will help avoid delays and ensure a smoother workflow. +{% include end-info.html %} + # FAQ ## What are the hardware and software requirements for the QuickBooks Desktop connector? diff --git a/docs/articles/expensify-classic/expenses/Add-an-expense.md b/docs/articles/expensify-classic/expenses/Add-an-expense.md index 92a96e989013..5f40ff377be6 100644 --- a/docs/articles/expensify-classic/expenses/Add-an-expense.md +++ b/docs/articles/expensify-classic/expenses/Add-an-expense.md @@ -2,7 +2,6 @@ title: Add an expense description: Create a new expense in Expensify --- -
You can add an expense automatically with SmartScan or enter the expense details manually. @@ -41,63 +40,189 @@ You can open any receipt and click **Fill out details myself** to add or edit th {% include end-selector.html %} -# Email a receipt - You can also email receipts to SmartScan by sending them to receipts@expensify.com from an email address tied to your Expensify account (either a primary or secondary email). SmartScan will automatically pull all of the details from the receipt, fill them in for you, and add the receipt to the Expenses tab on your account. {% include info.html %} **For copilots**: To ensure a receipt is routed to the Expensify account you are copiloting instead of your own account, email the receipt to receipts@expensify.com with the email address of the account you are copiloting as the subject line of the email. {% include end-info.html %} -# Add an expense manually +# Add a per diem expense + +A per diem (also called “per diem allowance” or “daily allowance”) is a fixed daily payment provided by an employer to cover expenses during business or work-related travel. These allowances simplify travel expense tracking and reimbursement for meals, lodging, and incidental expenses. + +{% include info.html %} +Before you can add a per diem expense, a Workspace Admin must [enable per diem expenses](https://help.expensify.com/articles/expensify-classic/workspaces/Enable-per-diem-expenses) for the workspace and add the per diem rates. If you do not see an option for per diem rates, it is currently unavailable for your workspace, and you’ll need to reach out to one of your Workspace Admins for guidance. +{% include end-info.html %} + +To add a per diem expense, + +1. Click the **Expenses** tab. +2. Click **New Expense** and choose **Per Diem**. +3. Select your travel destination. + - If your trip involves multiple stops, create a separate per diem expense for each destination. +4. Select the start date, end date, start time, and end time for the trip. +5. Select a sub-rate. The available sub-rates are dependent on the trip duration. + - You can include meal deductions or overnight lodging costs if allowed by your workspace. +6. Enter any other required coding information, such as the category, description, or report, and click **Save**. + +# Add a mileage expense + +You can track your mileage-related expenses by logging your trips in Expensify. You have a couple of different options for logging distance: + +- Web app: + - **Manually create**: Manually enter the number of miles for the trip + - **Create from map**: Automatically determine the trip distance based on the start and end location. +- Mobile app: + - **Manually create**: Manually enter the miles for the trip and your mileage rate + - **Odometer**: Enter your odometer reading before and after the trip + - **Start GPS**: Currently under development and unavailable for use. + +{% include info.html %} +When adding a distance expense, the rates available are determined by the rates set in your [workspace rate settings](https://help.expensify.com/articles/expensify-classic/workspaces/Set-time-and-distance-rates). To update these rates or add a new rate, you must be a Workspace Admin. +{% include end-info.html %} {% include selector.html values="desktop, mobile" %} {% include option.html value="desktop" %} 1. Click the **Expenses** tab. -2. Click the + icon in the top right. -3. Select the type of expense. - - **Manually create**: Manually enter receipt details. - - **Scan receipt**: Upload a saved image of a receipt. - - **Create multiple**: Manually enter multiple expenses at once. - - **Time**: Create an expense based on hours. - - **Distance**: Create an expense based on distance. - - Manually Create: Manually enter the distance details for the expense. - - Create from Map: Enter the start and end destination and Expensify will help you create a receipt for the trip. -4. Click **Save**. +2. Click **New Expense**. +3. Select the expense type. + - **Manually create**: + - Enter the number of miles for the trip. + - Select your rate. + - If desired, select the category, add a description, or select a report to add the expense to. + - Click **Save**. + - **Create from map**: + - Add your start location as point A. + - Add your end location as point B. + - If applicable, click **Add Destination** to add additional stops. + - To generate a map receipt, leave the Create Receipt checkbox selected. + - Click **Save**. + - Select your rate. + - If desired, select the category, add a description, or select a report to add the expense to. + - Click **Save**. + {% include end-option.html %} {% include option.html value="mobile" %} -1. Tap the ☰ menu icon in the top left. -2. Tap **Expenses**. -3. Tap the + icon in the top right. -4. Tap the correct expense type and enter the expense details. - - **Manually create**: Manually enter receipt details. - - **Time**: Enter work time and rate. - - **Manually create (Distance)**: Manually enter trip details by total distance. - - **Odometer**: Manually enter trip details by start and end odometer readings. - - **Start GPS**: Track distance while using the Expensify app to automatically calculate the distance in real time during the trip. -5. Tap **Save**. +1. Click the + icon in the top right corner. +2. Under the Distance section, select the expense type. + - **Manually create**: + - Enter your mileage. + - Select your rate. + - If desired, click **More Options** to select the category, add a description, or select a report to add the expense to. + - Click **Save**. + - **Odometer**: + - Enter your vehicle’s odometer reading before the trip. + - Enter your vehicle’s odometer reading after the trip. + - Select your rate. + - If desired, click **More Options** to select the category, add a description, or select a report to add the expense to. + - Click **Save**. {% include end-option.html %} {% include end-selector.html %} +# Add a group expense + +Capture group and event expenses with Attendee Tracking by documenting who attended and the cost per attendee. The amount is always divided evenly between all attendees—different amounts cannot be allocated to specific attendees. To divide the amounts differently, you’ll first have to split the expense. + {% include info.html %} -If you are an employee under a company workspace, you may not see all of the different expense type options depending on your company’s workspace settings. +Attendees added to an expense will not be notified that they were added to an expense, nor will they share in the expense or be requested to pay for any portion of the expense. {% include end-info.html %} +{% include selector.html values="desktop, mobile" %} + +{% include option.html value="desktop" %} +1. Click the **Expenses** tab. +2. Click the expense you want to add attendees to. +3. Click the attendees field and enter the name or email address of the attendee. + - If the attendee is a member of your workspace, you can select their name from the list. + - If the attendee is not a member of your workspace, enter their full name or email address and press Enter on your keyboard to add them as a new attendee. +4. Click **Save**. + +Once added, you’ll also see the list of attendees in the expense overview on the Expenses tab. To see the cost per employee, hover over the receipt total. These details are also available on any report that you add the expense to. +{% include end-option.html %} + +{% include option.html value="mobile" %} +1. Tap the **Expenses** tab. +2. Tap the expense you want to add attendees to. +3. Scroll down to the bottom and tap **More Options**. +4. Tap the attendees field and enter the name or email address of the attendee. + - If the attendee is a member of your workspace, you can select their name from the list. + - If the attendee is not a member of your workspace, enter their full name or email address and press Enter on your keyboard to add them as a new attendee. +5. Tap **Save**. + +Attendees will also be listed on any report that you add the expense to. + +{% include end-option.html %} + +{% include end-selector.html %} + +# Add expenses in bulk + +You can upload bulk receipt images or add receipt details in bulk. + +## SmartScan receipt images in bulk + +{% include selector.html values="desktop, mobile" %} + +{% include option.html value="desktop" %} +1. Click the **Expenses** tab. +2. Drag and drop up to 10 images or PDF receipts at once from your computer’s files. You can drop them anywhere on the Expense page where you see a green plus icon next to your mouse cursor. +{% include end-option.html %} + +{% include option.html value="mobile" %} +1. Open the mobile app and tap the camera icon in the bottom right corner. +2. Tap the camera icon in the right corner to select the Rapid Fire mode. +3. Take a clear photo of each receipt. +4. When all receipts are captured, tap the X in the left corner to close the camera. +{% include end-option.html %} + +{% include end-selector.html %} + +## Manually add receipt details in bulk + +*Note: This process is currently not available from the mobile app and must be completed from the Expensify website.* + +1. Click the **Expenses** tab. +2. Click **New Expense** and select **Create Multiple**. +3. Enter the expense details for up to 10 expenses and click **Save**. + +## Upload personal expenses via CSV, XLS, etc. + +*Note: This process is currently not available from the mobile app and must be completed from the Expensify website.* + +1. Hover over Settings, then click **Account**. +2. Click the **Credit Card Import** tab. +3. Under Personal Cards, click **Import Transactions from File**. +4. Click **Upload** and select a .csv, .xls, .ofx, or a .qfx file. + {% include faq-begin.md %} **What’s the difference between a reimbursable and non-reimbursable expense?** -- Reimbursable expenses are things that you pay for with your own money that the company has agreed to pay you back for (like business travel paid for with personal funds). -- Non-reimbursable expenses are things you pay for with company money that need to be documented for accounting purposes (like a lunch paid for with a company card). +- **Reimbursable expenses**: Expenses that the company has agreed to pay you back for. This may include: + - Cash & personal card: Expenses paid for by the employee on behalf of the business. + - Per diem: Expenses for a daily or partial daily rate [configured in your Workspace](https://help.expensify.com/articles/expensify-classic/workspaces/Enable-per-diem-expenses). + - Time: An hourly rate for your employees or jobs as [set for your workspace](https://help.expensify.com/articles/expensify-classic/workspaces/Set-time-and-distance-rates). This expense type is usually used by contractors or small businesses billing the customer via [Expensify Invoicing](https://help.expensify.com/articles/expensify-classic/workspaces/Set-Up-Invoicing). + - Distance: Expenses related to business travel. +- **Non-reimbursable expenses**: Expenses are things you pay for with company money that need to be documented for accounting purposes (like a lunch paid for with a company card). +- **Billable expenses**: Business or employee expenses that must be billed to a specific client or vendor. This option is for tracking expenses for invoicing to customers, clients, or other departments. Any kind of expense can be billable, in _addition_ to being either reimbursable or non-reimbursable. + +You can also see a breakdown of these expense types on your report and can even organize the report by them. {% include info.html %} If you are an employee under a company workspace, your expenses may automatically be configured as reimbursable or non-reimbursable depending on the details that are entered. If an expense is incorrectly labeled, you must reach out to an admin to have it corrected. {% include end-info.html %} +**Why don't I see the option for one of these types of expenses?** + +If you are an employee under a company workspace, you may not see all of the different expense type options depending on your company’s workspace settings. + +**How do I edit my per diem expenses?** + +Per diem expenses cannot be amended. To make changes, you must delete the expense and recreate it. + {% include faq-end.md %} -
diff --git a/docs/articles/expensify-classic/expenses/Add-expenses-in-bulk.md b/docs/articles/expensify-classic/expenses/Add-expenses-in-bulk.md deleted file mode 100644 index 6ee84e1ead15..000000000000 --- a/docs/articles/expensify-classic/expenses/Add-expenses-in-bulk.md +++ /dev/null @@ -1,44 +0,0 @@ ---- -title: Add expenses in bulk -description: Add multiple expenses at one time ---- -
- -You can upload bulk receipt images or add receipt details in bulk. - -# SmartScan receipt images in bulk - -{% include selector.html values="desktop, mobile" %} - -{% include option.html value="desktop" %} -1. Click the **Expenses** tab. -2. Drag and drop up to 10 images or PDF receipts at once from your computer’s files. You can drop them anywhere on the Expense page where you see a green plus icon next to your mouse cursor. -{% include end-option.html %} - -{% include option.html value="mobile" %} -1. Open the mobile app and tap the camera icon in the bottom right corner. -2. Tap the camera icon in the right corner to select the Rapid Fire mode. -3. Take a clear photo of each receipt. -4. When all receipts are captured, tap the X in the left corner to close the camera. -{% include end-option.html %} - -{% include end-selector.html %} - -# Manually add receipt details in bulk - -*Note: This process is currently not available from the mobile app and must be completed from the Expensify website.* - -1. Click the **Expenses** tab. -2. Click **New Expense** and select **Create Multiple**. -3. Enter the expense details for up to 10 expenses and click **Save**. - -# Upload personal expenses via CSV, XLS, etc. - -*Note: This process is currently not available from the mobile app and must be completed from the Expensify website.* - -1. Hover over Settings, then click **Account**. -2. Click the **Credit Card Import** tab. -3. Under Personal Cards, click **Import Transactions from File**. -4. Click **Upload** and select a .csv, .xls, .ofx, or a .qfx file. - -
diff --git a/docs/articles/expensify-classic/expenses/Track-group-expenses.md b/docs/articles/expensify-classic/expenses/Track-group-expenses.md deleted file mode 100644 index 82921b0e8cd3..000000000000 --- a/docs/articles/expensify-classic/expenses/Track-group-expenses.md +++ /dev/null @@ -1,41 +0,0 @@ ---- -title: Track group expenses -description: Use Attendee Tracking to track group expenses ---- -
- -Capture group and event expenses with Attendee Tracking by documenting who attended and the cost per attendee. The amount is always divided evenly between all attendees—different amounts cannot be allocated to specific attendees. To divide the amounts differently, you’ll first have to split the expense. - -{% include info.html %} -Attendees added to an expense will not be notified that they were added to an expense, nor will they share in the expense or be requested to pay for any portion of the expense. -{% include end-info.html %} - -{% include selector.html values="desktop, mobile" %} - -{% include option.html value="desktop" %} -1. Click the **Expenses** tab. -2. Click the expense you want to add attendees to. -3. Click the attendees field and enter the name or email address of the attendee. - - If the attendee is a member of your workspace, you can select their name from the list. - - If the attendee is not a member of your workspace, enter their full name or email address and press Enter on your keyboard to add them as a new attendee. -4. Click **Save**. - -Once added, you’ll also see the list of attendees in the expense overview on the Expenses tab. To see the cost per employee, hover over the receipt total. These details are also available on any report that you add the expense to. -{% include end-option.html %} - -{% include option.html value="mobile" %} -1. Tap the **Expenses** tab. -2. Tap the expense you want to add attendees to. -3. Scroll down to the bottom and tap **More Options**. -4. Tap the attendees field and enter the name or email address of the attendee. - - If the attendee is a member of your workspace, you can select their name from the list. - - If the attendee is not a member of your workspace, enter their full name or email address and press Enter on your keyboard to add them as a new attendee. -5. Tap **Save**. - -Attendees will also be listed on any report that you add the expense to. - -{% include end-option.html %} - -{% include end-selector.html %} - -
diff --git a/docs/articles/expensify-classic/expenses/Track-mileage-expenses.md b/docs/articles/expensify-classic/expenses/Track-mileage-expenses.md deleted file mode 100644 index e8b9ab0eac75..000000000000 --- a/docs/articles/expensify-classic/expenses/Track-mileage-expenses.md +++ /dev/null @@ -1,66 +0,0 @@ ---- -title: Track mileage expenses -description: Add mileage-related expenses ---- - -
- -You can track your mileage-related expenses by logging your trips in Expensify. You have a couple of different options for logging distance: - -- Web app: - - **Manually create**: Manually enter the number of miles for the trip - - **Create from map**: Automatically determine the trip distance based on the start and end location. -- Mobile app: - - **Manually create**: Manually enter the miles for the trip and your mileage rate - - **Odometer**: Enter your odometer reading before and after the trip - - **Start GPS**: Currently under development and unavailable for use. - -{% include info.html %} -When adding a distance expense, the rates available are determined by the rates set in your workspace rate settings. To update these rates or add a new rate, you must be a Workspace Admin. -{% include end-info.html %} - -{% include selector.html values="desktop, mobile" %} - -{% include option.html value="desktop" %} - -1. Click the **Expenses** tab. -2. Click **New Expense**. -3. Select the expense type. - - **Manually create**: - - Enter the number of miles for the trip. - - Select your rate. - - If desired, select the category, add a description, or select a report to add the expense to. - - Click **Save**. - - **Create from map**: - - Add your start location as point A. - - Add your end location as point B. - - If applicable, click **Add Destination** to add additional stops. - - To generate a map receipt, leave the Create Receipt checkbox selected. - - Click **Save**. - - Select your rate. - - If desired, select the category, add a description, or select a report to add the expense to. - - Click **Save**. - -{% include end-option.html %} - -{% include option.html value="mobile" %} - -1. Click the + icon in the top right corner. -2. Under the Distance section, select the expense type. - - **Manually create**: - - Enter your mileage. - - Select your rate. - - If desired, click **More Options** to select the category, add a description, or select a report to add the expense to. - - Click **Save**. - - **Odometer**: - - Enter your vehicle’s odometer reading before the trip. - - Enter your vehicle’s odometer reading after the trip. - - Select your rate. - - If desired, click **More Options** to select the category, add a description, or select a report to add the expense to. - - Click **Save**. -{% include end-option.html %} - -{% include end-selector.html %} - -
- diff --git a/docs/articles/expensify-classic/expenses/Track-per-diem-expenses.md b/docs/articles/expensify-classic/expenses/Track-per-diem-expenses.md deleted file mode 100644 index 88dd91997592..000000000000 --- a/docs/articles/expensify-classic/expenses/Track-per-diem-expenses.md +++ /dev/null @@ -1,34 +0,0 @@ ---- -title: Track per diem expenses -description: Add daily allowance expenses for business travel ---- -
- -A per diem (also called “per diem allowance” or “daily allowance”) is a fixed daily payment provided by an employer to cover expenses during business or work-related travel. These allowances simplify travel expense tracking and reimbursement for meals, lodging, and incidental expenses. - -{% include info.html %} -Before you can add a per diem expense, a Workspace Admin must enable per diem expenses for the workspace and add the per diem rates. If you do not see an option for per diem rates, it is currently unavailable for your workspace, and you’ll need to reach out to one of your Workspace Admins for guidance. -{% include end-info.html %} - -To add a per diem expense, - -1. Click the **Expenses** tab. -2. Click **New Expense** and choose **Per Diem**. -3. Select your travel destination. - - If your trip involves multiple stops, create a separate per diem expense for each destination. -4. Select the start date, end date, start time, and end time for the trip. -5. Select a sub-rate. The available sub-rates are dependent on the trip duration. - - You can include meal deductions or overnight lodging costs if allowed by your workspace. -6. Enter any other required coding information, such as the category, description, or report, and click **Save**. - -# FAQs - -**How do I edit my per diem expenses?** - -Per diem expenses cannot be amended. To make changes, you must delete the expense and recreate it. - -**What if my admin requires daily per diem submissions?** - -No problem! Create a separate per diem expense for each day of your trip. - -
diff --git a/docs/redirects.csv b/docs/redirects.csv index 06fd7c1ef502..f9029ea6194e 100644 --- a/docs/redirects.csv +++ b/docs/redirects.csv @@ -591,3 +591,7 @@ https://help.expensify.com/articles/expensify-classic/articles/expensify-classic https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/payments/Pay-Bills,https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/payments/Create-and-Pay-Bills https://help.expensify.com/articles/new-expensify/billing-and-subscriptions/add-a-payment-card-and-view-your-subscription,https://help.expensify.com/articles/new-expensify/billing-and-subscriptions/Add-a-payment-card-and-view-your-subscription https://help.expensify.com/articles/new-expensify/billing-and-subscriptions/Billing-page-coming-soon,https://help.expensify.com/articles/new-expensify/billing-and-subscriptions/Billing-page +https://help.expensify.com/articles/expensify-classic/expenses/Add-expenses-in-bulk,https://help.expensify.com/articles/expensify-classic/expenses/Add-an-expense +https://help.expensify.com/articles/expensify-classic/expenses/Track-group-expenses,https://help.expensify.com/articles/expensify-classic/expenses/Add-an-expense +https://help.expensify.com/articles/expensify-classic/expenses/Track-mileage-expenses,https://help.expensify.com/articles/expensify-classic/expenses/Add-an-expense +https://help.expensify.com/articles/expensify-classic/expenses/Track-per-diem-expenses,https://help.expensify.com/articles/expensify-classic/expenses/Add-an-expense diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 74bcff5bf320..e90fdbe50255 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -426,6 +426,7 @@ platform :ios do api_key_path: "./ios/ios-fastlane-json-key.json", distribute_external: true, notify_external_testers: true, + reject_build_waiting_for_review: true, changelog: "Thank you for beta testing New Expensify, this version includes bug fixes and improvements.", groups: ["Applause", "Beta Testers", "Expensify Employees"], demo_account_required: true, diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index 10e283ae43e9..ffd541c2ee35 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -19,7 +19,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 9.0.56 + 9.0.57 CFBundleSignature ???? CFBundleURLTypes @@ -40,7 +40,7 @@ CFBundleVersion - 9.0.56.7 + 9.0.57.8 FullStory OrgId diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index f95e27e3a725..88b241529416 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -15,10 +15,10 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 9.0.56 + 9.0.57 CFBundleSignature ???? CFBundleVersion - 9.0.56.7 + 9.0.57.8 diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index 9ed1578badb6..500cbc12618d 100644 --- a/ios/NotificationServiceExtension/Info.plist +++ b/ios/NotificationServiceExtension/Info.plist @@ -11,9 +11,9 @@ CFBundleName $(PRODUCT_NAME) CFBundleShortVersionString - 9.0.56 + 9.0.57 CFBundleVersion - 9.0.56.7 + 9.0.57.8 NSExtension NSExtensionPointIdentifier diff --git a/ios/Podfile.lock b/ios/Podfile.lock index d1851cbce1af..6494782a6ec0 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -2503,7 +2503,7 @@ PODS: - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - Yoga - - RNReanimated (3.15.1): + - RNReanimated (3.15.3): - DoubleConversion - glog - hermes-engine @@ -2523,10 +2523,10 @@ PODS: - ReactCodegen - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - - RNReanimated/reanimated (= 3.15.1) - - RNReanimated/worklets (= 3.15.1) + - RNReanimated/reanimated (= 3.15.3) + - RNReanimated/worklets (= 3.15.3) - Yoga - - RNReanimated/reanimated (3.15.1): + - RNReanimated/reanimated (3.15.3): - DoubleConversion - glog - hermes-engine @@ -2547,7 +2547,7 @@ PODS: - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - Yoga - - RNReanimated/worklets (3.15.1): + - RNReanimated/worklets (3.15.3): - DoubleConversion - glog - hermes-engine @@ -3269,7 +3269,7 @@ SPEC CHECKSUMS: rnmapbox-maps: 460d6ff97ae49c7d5708c3212c6521697c36a0c4 RNPermissions: 0b1429b55af59d1d08b75a8be2459f65a8ac3f28 RNReactNativeHapticFeedback: 73756a3477a5a622fa16862a3ab0d0fc5e5edff5 - RNReanimated: 76901886830e1032f16bbf820153f7dc3f02d51d + RNReanimated: f46df3b08d5d59cd83c47bb6697ce88e565e0dc7 RNScreens: de6e57426ba0e6cbc3fb5b4f496e7f08cb2773c2 RNShare: bd4fe9b95d1ee89a200778cc0753ebe650154bb0 RNSound: 6c156f925295bdc83e8e422e7d8b38d33bc71852 diff --git a/package-lock.json b/package-lock.json index 011a8f736cb1..c5fa74b95e5a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "9.0.56-7", + "version": "9.0.57-8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "9.0.56-7", + "version": "9.0.57-8", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -104,7 +104,7 @@ "react-native-plaid-link-sdk": "11.11.0", "react-native-qrcode-svg": "6.3.11", "react-native-quick-sqlite": "git+https://github.com/margelo/react-native-quick-sqlite#99f34ebefa91698945f3ed26622e002bd79489e0", - "react-native-reanimated": "3.15.1", + "react-native-reanimated": "3.15.3", "react-native-release-profiler": "^0.2.1", "react-native-render-html": "6.3.1", "react-native-safe-area-context": "4.10.9", @@ -35627,9 +35627,10 @@ } }, "node_modules/react-native-reanimated": { - "version": "3.15.1", - "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-3.15.1.tgz", - "integrity": "sha512-DbBeUUExtJ1x1nfE94I8qgDgWjq5ztM3IO/+XFO+agOkPeVpBs5cRnxHfJKrjqJ2MgwhJOUDmtHxo+tDsoeitg==", + "version": "3.15.3", + "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-3.15.3.tgz", + "integrity": "sha512-5QBk/7PZvZ98Adxm4MRyglwzsRzReTQIe4Hd2wbBBAZ68IC4OYKvsc8cPEjgx3/1mG8HgHFYhbcDe5U2RjeFqw==", + "license": "MIT", "dependencies": { "@babel/plugin-transform-arrow-functions": "^7.0.0-0", "@babel/plugin-transform-class-properties": "^7.0.0-0", diff --git a/package.json b/package.json index 214660f7ae67..5bd1642b2917 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "9.0.56-7", + "version": "9.0.57-8", "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.", @@ -161,7 +161,7 @@ "react-native-plaid-link-sdk": "11.11.0", "react-native-qrcode-svg": "6.3.11", "react-native-quick-sqlite": "git+https://github.com/margelo/react-native-quick-sqlite#99f34ebefa91698945f3ed26622e002bd79489e0", - "react-native-reanimated": "3.15.1", + "react-native-reanimated": "3.15.3", "react-native-release-profiler": "^0.2.1", "react-native-render-html": "6.3.1", "react-native-safe-area-context": "4.10.9", diff --git a/patches/react-native+0.75.2+018+Add-regex-to-TextInput.patch b/patches/react-native+0.75.2+018+Add-regex-to-TextInput.patch deleted file mode 100644 index 6c511d8cbec1..000000000000 --- a/patches/react-native+0.75.2+018+Add-regex-to-TextInput.patch +++ /dev/null @@ -1,299 +0,0 @@ -diff --git a/node_modules/react-native/Libraries/Components/TextInput/AndroidTextInputNativeComponent.js b/node_modules/react-native/Libraries/Components/TextInput/AndroidTextInputNativeComponent.js -index 770dfee..73e439b 100644 ---- a/node_modules/react-native/Libraries/Components/TextInput/AndroidTextInputNativeComponent.js -+++ b/node_modules/react-native/Libraries/Components/TextInput/AndroidTextInputNativeComponent.js -@@ -329,6 +329,12 @@ export type NativeProps = $ReadOnly<{| - */ - returnKeyType?: WithDefault, - -+ /** -+ * Restricts the text value to match the specified regular expression. Use this -+ * instead of implementing the logic in JS to avoid flicker. -+ */ -+ regex?: ?string, -+ - /** - * Limits the maximum number of characters that can be entered. Use this - * instead of implementing the logic in JS to avoid flicker. -@@ -699,6 +705,7 @@ export const __INTERNAL_VIEW_CONFIG: PartialViewConfig = { - process: require('../../StyleSheet/processColor').default, - }, - maxLength: true, -+ regex: true, - selectTextOnFocus: true, - textShadowRadius: true, - underlineColorAndroid: { -diff --git a/node_modules/react-native/Libraries/Components/TextInput/RCTTextInputViewConfig.js b/node_modules/react-native/Libraries/Components/TextInput/RCTTextInputViewConfig.js -index dbfe5d5..1f359ba 100644 ---- a/node_modules/react-native/Libraries/Components/TextInput/RCTTextInputViewConfig.js -+++ b/node_modules/react-native/Libraries/Components/TextInput/RCTTextInputViewConfig.js -@@ -151,6 +151,7 @@ const RCTTextInputViewConfig = { - autoFocus: true, - lineBreakStrategyIOS: true, - smartInsertDelete: true, -+ regex: true, - ...ConditionallyIgnoredEventHandlers({ - onClear: true, - onChange: true, -diff --git a/node_modules/react-native/Libraries/Components/TextInput/TextInput.d.ts b/node_modules/react-native/Libraries/Components/TextInput/TextInput.d.ts -index 20501f7..76f30b9 100644 ---- a/node_modules/react-native/Libraries/Components/TextInput/TextInput.d.ts -+++ b/node_modules/react-native/Libraries/Components/TextInput/TextInput.d.ts -@@ -701,6 +701,12 @@ export interface TextInputProps - */ - inputMode?: InputModeOptions | undefined; - -+ /** -+ * Restricts the text value to match the specified regular expression. Use this -+ * instead of implementing the logic in JS to avoid flicker. -+ */ -+ regex?: string | undefined; -+ - /** - * Limits the maximum number of characters that can be entered. - * Use this instead of implementing the logic in JS to avoid flicker. -diff --git a/node_modules/react-native/Libraries/Components/TextInput/TextInput.flow.js b/node_modules/react-native/Libraries/Components/TextInput/TextInput.flow.js -index 2f35731..5bb94bc 100644 ---- a/node_modules/react-native/Libraries/Components/TextInput/TextInput.flow.js -+++ b/node_modules/react-native/Libraries/Components/TextInput/TextInput.flow.js -@@ -697,6 +697,12 @@ export type Props = $ReadOnly<{| - */ - maxFontSizeMultiplier?: ?number, - -+ /** -+ * Restricts the text value to match the specified regular expression. Use this -+ * instead of implementing the logic in JS to avoid flicker. -+ */ -+ regex?: ?string, -+ - /** - * Limits the maximum number of characters that can be entered. Use this - * instead of implementing the logic in JS to avoid flicker. -diff --git a/node_modules/react-native/Libraries/Components/TextInput/TextInput.js b/node_modules/react-native/Libraries/Components/TextInput/TextInput.js -index 8cfde15..4f3345c 100644 ---- a/node_modules/react-native/Libraries/Components/TextInput/TextInput.js -+++ b/node_modules/react-native/Libraries/Components/TextInput/TextInput.js -@@ -731,6 +731,12 @@ export type Props = $ReadOnly<{| - */ - maxFontSizeMultiplier?: ?number, - -+ /** -+ * Restricts the text value to match the specified regular expression. Use this -+ * instead of implementing the logic in JS to avoid flicker. -+ */ -+ regex?: ?string, -+ - /** - * Limits the maximum number of characters that can be entered. Use this - * instead of implementing the logic in JS to avoid flicker. -diff --git a/node_modules/react-native/Libraries/Text/TextInput/RCTBaseTextInputViewManager.mm b/node_modules/react-native/Libraries/Text/TextInput/RCTBaseTextInputViewManager.mm -index e367394..95f21f2 100644 ---- a/node_modules/react-native/Libraries/Text/TextInput/RCTBaseTextInputViewManager.mm -+++ b/node_modules/react-native/Libraries/Text/TextInput/RCTBaseTextInputViewManager.mm -@@ -59,6 +59,7 @@ @implementation RCTBaseTextInputViewManager { - RCT_EXPORT_VIEW_PROPERTY(inputAccessoryViewID, NSString) - RCT_EXPORT_VIEW_PROPERTY(textContentType, NSString) - RCT_EXPORT_VIEW_PROPERTY(passwordRules, NSString) -+RCT_EXPORT_VIEW_PROPERTY(regex, NSString) - - RCT_EXPORT_VIEW_PROPERTY(onChange, RCTBubblingEventBlock) - RCT_EXPORT_VIEW_PROPERTY(onKeyPressSync, RCTDirectEventBlock) -diff --git a/node_modules/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm b/node_modules/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm -index db7cba4..f85f95a 100644 ---- a/node_modules/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm -+++ b/node_modules/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm -@@ -34,6 +34,7 @@ @implementation RCTTextInputComponentView { - UIView *_backedTextInputView; - NSUInteger _mostRecentEventCount; - NSAttributedString *_lastStringStateWasUpdatedWith; -+ NSRegularExpression *_regex; - - /* - * UIKit uses either UITextField or UITextView as its UIKit element for . UITextField is for single line -@@ -224,6 +225,13 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared & - if (newTextInputProps.inputAccessoryViewID != oldTextInputProps.inputAccessoryViewID) { - _backedTextInputView.inputAccessoryViewID = RCTNSStringFromString(newTextInputProps.inputAccessoryViewID); - } -+ -+ if (newTextInputProps.regex != oldTextInputProps.regex) { -+ _regex = [NSRegularExpression regularExpressionWithPattern:RCTNSStringFromString(newTextInputProps.regex) -+ options:0 -+ error:nil]; -+ } -+ - [super updateProps:props oldProps:oldProps]; - - [self setDefaultInputAccessoryView]; -@@ -359,6 +367,14 @@ - (NSString *)textInputShouldChangeText:(NSString *)text inRange:(NSRange)range - } - } - -+ if (_regex) { -+ NSMutableString *newString = [_backedTextInputView.attributedText.string mutableCopy]; -+ [newString replaceCharactersInRange:range withString:text]; -+ if ([_regex numberOfMatchesInString:newString options:0 range:NSMakeRange(0, newString.length)] == 0) { -+ return nil; -+ } -+ } -+ - if (props.maxLength) { - NSInteger allowedLength = props.maxLength - _backedTextInputView.attributedText.string.length + range.length; - -diff --git a/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputManager.java b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputManager.java -index 2cceb14..8fdc0c1 100644 ---- a/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputManager.java -+++ b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputManager.java -@@ -824,6 +824,47 @@ public class ReactTextInputManager extends BaseViewManager 0) { -+ LinkedList list = new LinkedList<>(); -+ for (InputFilter currentFilter : currentFilters) { -+ if (!(currentFilter instanceof RegexFilter)) { -+ list.add(currentFilter); -+ } -+ } -+ if (!list.isEmpty()) { -+ newFilters = (InputFilter[]) list.toArray(new InputFilter[list.size()]); -+ } -+ } -+ } else { -+ if (currentFilters.length > 0) { -+ newFilters = currentFilters; -+ boolean replaced = false; -+ for (int i = 0; i < currentFilters.length; i++) { -+ if (currentFilters[i] instanceof RegexFilter) { -+ currentFilters[i] = new RegexFilter(regex); -+ replaced = true; -+ } -+ } -+ if (!replaced) { -+ newFilters = new InputFilter[currentFilters.length + 1]; -+ System.arraycopy(currentFilters, 0, newFilters, 0, currentFilters.length); -+ newFilters[currentFilters.length] = new RegexFilter(regex); -+ } -+ } else { -+ newFilters = new InputFilter[1]; -+ newFilters[0] = new RegexFilter(regex); -+ } -+ } -+ -+ view.setFilters(newFilters); -+ } -+ - @ReactProp(name = "maxLength") - public void setMaxLength(ReactEditText view, @Nullable Integer maxLength) { - InputFilter[] currentFilters = view.getFilters(); -@@ -854,7 +895,7 @@ public class ReactTextInputManager extends BaseViewManager `[Take a self-guided product tour](${navatticURL}) and learn about everything Expensify has to offer.`, +}; + const onboardingEmployerOrSubmitMessage: OnboardingMessageType = { message: 'Getting paid back is as easy as sending a message. Let’s go over the basics.', video: { @@ -99,6 +106,7 @@ const onboardingEmployerOrSubmitMessage: OnboardingMessageType = { height: 960, }, tasks: [ + selfGuidedTourTask, { type: 'submitExpense', autoCompleted: false, @@ -264,6 +272,7 @@ type OnboardingTaskType = { workspaceMembersLink: string; integrationName: string; workspaceAccountingLink: string; + navatticURL: string; }>, ) => string); }; @@ -305,6 +314,9 @@ const CONST = { ANIMATED_HIGHLIGHT_END_DURATION: 2000, ANIMATED_TRANSITION: 300, ANIMATED_TRANSITION_FROM_VALUE: 100, + ANIMATED_PROGRESS_BAR_DELAY: 300, + ANIMATED_PROGRESS_BAR_OPACITY_DURATION: 300, + ANIMATED_PROGRESS_BAR_DURATION: 750, ANIMATION_IN_TIMING: 100, ANIMATION_DIRECTION: { IN: 'in', @@ -620,7 +632,6 @@ const CONST = { COMPANY_CARD_FEEDS: 'companyCardFeeds', DIRECT_FEEDS: 'directFeeds', NETSUITE_USA_TAX: 'netsuiteUsaTax', - NEW_DOT_COPILOT: 'newDotCopilot', COMBINED_TRACK_SUBMIT: 'combinedTrackSubmit', CATEGORY_AND_TAG_APPROVERS: 'categoryAndTagApprovers', PER_DIEM: 'newDotPerDiem', @@ -879,8 +890,10 @@ const CONST = { // Use Environment.getEnvironmentURL to get the complete URL with port number DEV_NEW_EXPENSIFY_URL: 'https://dev.new.expensify.com:', NAVATTIC: { - ADMIN_TOUR: 'https://expensify.navattic.com/kh204a7', - EMPLOYEE_TOUR: 'https://expensify.navattic.com/35609gb', + ADMIN_TOUR_PRODUCTION: 'https://expensify.navattic.com/kh204a7', + ADMIN_TOUR_STAGING: 'https://expensify.navattic.com/3i300k18', + EMPLOYEE_TOUR_PRODUCTION: 'https://expensify.navattic.com/35609gb', + EMPLOYEE_TOUR_STAGING: 'https://expensify.navattic.com/cf15002s', }, OLDDOT_URLS: { @@ -2736,7 +2749,6 @@ const CONST = { DAILY: 'daily', MONTHLY: 'monthly', }, - CARD_TITLE_INPUT_LIMIT: 255, MANAGE_EXPENSIFY_CARDS_ARTICLE_LINK: 'https://help.expensify.com/articles/new-expensify/expensify-card/Manage-Expensify-Cards', }, COMPANY_CARDS: { @@ -4882,6 +4894,7 @@ const CONST = { '\n' + '*Your new workspace is ready! It’ll keep all of your spend (and chats) in one place.*', }, + selfGuidedTourTask, { type: 'meetGuide', autoCompleted: false, @@ -4986,7 +4999,10 @@ const CONST = { }, ], }, - [onboardingChoices.PERSONAL_SPEND]: onboardingPersonalSpendMessage, + [onboardingChoices.PERSONAL_SPEND]: { + ...onboardingPersonalSpendMessage, + tasks: [selfGuidedTourTask, ...onboardingPersonalSpendMessage.tasks], + }, [onboardingChoices.CHAT_SPLIT]: { message: 'Splitting bills with friends is as easy as sending a message. Here’s how.', video: { @@ -4997,6 +5013,7 @@ const CONST = { height: 960, }, tasks: [ + selfGuidedTourTask, { type: 'startChat', autoCompleted: false, diff --git a/src/components/AccountSwitcher.tsx b/src/components/AccountSwitcher.tsx index ad58294c0cc8..9a90de17595d 100644 --- a/src/components/AccountSwitcher.tsx +++ b/src/components/AccountSwitcher.tsx @@ -5,7 +5,6 @@ import {useOnyx} from 'react-native-onyx'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; -import usePermissions from '@hooks/usePermissions'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -34,7 +33,6 @@ function AccountSwitcher() { const theme = useTheme(); const {translate} = useLocalize(); const {isOffline} = useNetwork(); - const {canUseNewDotCopilot} = usePermissions(); const {shouldUseNarrowLayout} = useResponsiveLayout(); const [account] = useOnyx(ONYXKEYS.ACCOUNT); const [session] = useOnyx(ONYXKEYS.SESSION); @@ -47,7 +45,7 @@ function AccountSwitcher() { const delegators = account?.delegatedAccess?.delegators ?? []; const isActingAsDelegate = !!account?.delegatedAccess?.delegate ?? false; - const canSwitchAccounts = canUseNewDotCopilot && (delegators.length > 0 || isActingAsDelegate); + const canSwitchAccounts = delegators.length > 0 || isActingAsDelegate; const createBaseMenuItem = ( personalDetails: PersonalDetails | undefined, @@ -87,7 +85,7 @@ function AccountSwitcher() { } const delegatePersonalDetails = PersonalDetailsUtils.getPersonalDetailByEmail(delegateEmail); - const error = ErrorUtils.getLatestErrorField(account?.delegatedAccess, 'connect'); + const error = ErrorUtils.getLatestError(account?.delegatedAccess?.errorFields?.disconnect); return [ createBaseMenuItem(delegatePersonalDetails, error, { @@ -105,8 +103,9 @@ function AccountSwitcher() { const delegatorMenuItems: PopoverMenuItem[] = delegators .filter(({email}) => email !== currentUserPersonalDetails.login) - .map(({email, role, errorFields}) => { - const error = ErrorUtils.getLatestErrorField({errorFields}, 'connect'); + .map(({email, role}) => { + const errorFields = account?.delegatedAccess?.errorFields ?? {}; + const error = ErrorUtils.getLatestError(errorFields?.connect?.[email]); const personalDetails = PersonalDetailsUtils.getPersonalDetailByEmail(email); return createBaseMenuItem(personalDetails, error, { badgeText: translate('delegate.role', {role}), diff --git a/src/components/AmountForm.tsx b/src/components/AmountForm.tsx index de3a1fe39829..a230dfa1af8d 100644 --- a/src/components/AmountForm.tsx +++ b/src/components/AmountForm.tsx @@ -1,6 +1,6 @@ import type {ForwardedRef} from 'react'; import React, {forwardRef, useCallback, useEffect, useMemo, useRef, useState} from 'react'; -import type {NativeSyntheticEvent, TextInputSelectionChangeEventData} from 'react-native'; +import type {NativeSyntheticEvent} from 'react-native'; import {View} from 'react-native'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -16,7 +16,7 @@ import TextInput from './TextInput'; import isTextInputFocused from './TextInput/BaseTextInput/isTextInputFocused'; import type {BaseTextInputProps, BaseTextInputRef} from './TextInput/BaseTextInput/types'; import TextInputWithCurrencySymbol from './TextInputWithCurrencySymbol'; -import type TextInputWithCurrencySymbolProps from './TextInputWithCurrencySymbol/types'; +import type BaseTextInputWithCurrencySymbolProps from './TextInputWithCurrencySymbol/types'; type AmountFormProps = { /** Amount supplied by the FormProvider */ @@ -51,7 +51,7 @@ type AmountFormProps = { /** Number of decimals to display */ fixedDecimals?: number; -} & Pick & +} & Pick & Pick; /** @@ -238,7 +238,6 @@ function AmountForm( forwardDeletePressedRef.current = key === 'delete' || (allowedOS.includes(operatingSystem ?? '') && event.nativeEvent.ctrlKey && key === 'd'); }; - const regex = useMemo(() => MoneyRequestUtils.amountRegex(decimals, amountMaxLength), [decimals, amountMaxLength]); const formattedAmount = MoneyRequestUtils.replaceAllDigits(currentAmount, toLocaleDigit); const canUseTouchScreen = DeviceCapabilities.canUseTouchScreen(); @@ -262,7 +261,6 @@ function AmountForm( keyboardType={CONST.KEYBOARD_TYPE.DECIMAL_PAD} inputMode={CONST.INPUT_MODE.DECIMAL} errorText={errorText} - regex={regex} // eslint-disable-next-line react/jsx-props-no-spreading {...rest} /> @@ -292,17 +290,16 @@ function AmountForm( }} selectedCurrencyCode={currency} selection={selection} - onSelectionChange={(e: NativeSyntheticEvent) => { + onSelectionChange={(start, end) => { if (!shouldUpdateSelection) { return; } - setSelection(e.nativeEvent.selection); + setSelection({start, end}); }} onKeyPress={textInputKeyPress} isCurrencyPressable={isCurrencyPressable} style={[styles.iouAmountTextInput]} containerStyle={[styles.iouAmountTextInputContainer]} - regex={regex} // eslint-disable-next-line react/jsx-props-no-spreading {...rest} /> diff --git a/src/components/AmountTextInput.tsx b/src/components/AmountTextInput.tsx index 2e0d3e62afa0..52c32ce1f584 100644 --- a/src/components/AmountTextInput.tsx +++ b/src/components/AmountTextInput.tsx @@ -39,7 +39,7 @@ type AmountTextInputProps = { /** Hide the focus styles on TextInput */ hideFocusedState?: boolean; -} & Pick; +} & Pick; function AmountTextInput( { diff --git a/src/components/AmountWithoutCurrencyForm.tsx b/src/components/AmountWithoutCurrencyForm.tsx index 6a9fc22f68f8..78b7c84ecb54 100644 --- a/src/components/AmountWithoutCurrencyForm.tsx +++ b/src/components/AmountWithoutCurrencyForm.tsx @@ -1,8 +1,7 @@ -import React, {useCallback, useMemo, useState} from 'react'; +import React, {useCallback, useMemo} from 'react'; import type {ForwardedRef} from 'react'; -import type {NativeSyntheticEvent, TextInputSelectionChangeEventData} from 'react-native'; import useLocalize from '@hooks/useLocalize'; -import {addLeadingZero, amountRegex, replaceAllDigits, replaceCommasWithPeriod, stripSpacesFromAmount, validateAmount} from '@libs/MoneyRequestUtils'; +import {addLeadingZero, replaceAllDigits, replaceCommasWithPeriod, stripSpacesFromAmount, validateAmount} from '@libs/MoneyRequestUtils'; import CONST from '@src/CONST'; import TextInput from './TextInput'; import type {BaseTextInputProps, BaseTextInputRef} from './TextInput/BaseTextInput/types'; @@ -22,11 +21,6 @@ function AmountWithoutCurrencyForm( const {toLocaleDigit} = useLocalize(); const currentAmount = useMemo(() => (typeof amount === 'string' ? amount : ''), [amount]); - const [selection, setSelection] = useState({ - start: currentAmount.length, - end: currentAmount.length, - }); - const decimals = 2; /** * Sets the selection and the amount accordingly to the value passed to the input @@ -39,10 +33,7 @@ function AmountWithoutCurrencyForm( const newAmountWithoutSpaces = stripSpacesFromAmount(newAmount); const replacedCommasAmount = replaceCommasWithPeriod(newAmountWithoutSpaces); const withLeadingZero = addLeadingZero(replacedCommasAmount); - if (!validateAmount(withLeadingZero, decimals)) { - // Use a shallow copy of selection to trigger setSelection - // More info: https://github.com/Expensify/App/issues/16385 - setSelection((prevSelection) => ({...prevSelection})); + if (!validateAmount(withLeadingZero, 2)) { return; } onInputChange?.(withLeadingZero); @@ -50,17 +41,12 @@ function AmountWithoutCurrencyForm( [onInputChange], ); - const regex = useMemo(() => amountRegex(decimals), [decimals]); const formattedAmount = replaceAllDigits(currentAmount, toLocaleDigit); return ( ) => { - setSelection(e.nativeEvent.selection); - }} inputID={inputID} name={name} label={label} @@ -69,7 +55,6 @@ function AmountWithoutCurrencyForm( role={role} ref={ref} keyboardType={CONST.KEYBOARD_TYPE.DECIMAL_PAD} - regex={regex} // eslint-disable-next-line react/jsx-props-no-spreading {...rest} /> diff --git a/src/components/AttachmentPicker/index.native.tsx b/src/components/AttachmentPicker/index.native.tsx index bc4467e82f01..5305155ae495 100644 --- a/src/components/AttachmentPicker/index.native.tsx +++ b/src/components/AttachmentPicker/index.native.tsx @@ -16,6 +16,8 @@ import useArrowKeyFocusManager from '@hooks/useArrowKeyFocusManager'; import useKeyboardShortcut from '@hooks/useKeyboardShortcut'; import useLocalize from '@hooks/useLocalize'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import * as FileUtils from '@libs/fileDownload/FileUtils'; import CONST from '@src/CONST'; @@ -117,6 +119,8 @@ function AttachmentPicker({ }: AttachmentPickerProps) { const styles = useThemeStyles(); const [isVisible, setIsVisible] = useState(false); + const StyleUtils = useStyleUtils(); + const theme = useTheme(); const completeAttachmentSelection = useRef<(data: FileObject[]) => void>(() => {}); const onModalHide = useRef<() => void>(); @@ -444,6 +448,7 @@ function AttachmentPicker({ title={translate(item.textTranslationKey)} onPress={() => selectItem(item)} focused={focusedIndex === menuIndex} + wrapperStyle={StyleUtils.getItemBackgroundColorStyle(false, focusedIndex === menuIndex, theme.activeComponentBG, theme.hoverComponentBG)} /> ))} diff --git a/src/components/EmojiPicker/EmojiPickerMenuItem/index.tsx b/src/components/EmojiPicker/EmojiPickerMenuItem/index.tsx index 8aaf4a14e560..1629089dace5 100644 --- a/src/components/EmojiPicker/EmojiPickerMenuItem/index.tsx +++ b/src/components/EmojiPicker/EmojiPickerMenuItem/index.tsx @@ -68,8 +68,7 @@ function EmojiPickerMenuItem({ ref.current = el ?? null; }} style={({pressed}) => [ - isFocused ? themeStyles.emojiItemKeyboardHighlighted : {}, - isHovered || isHighlighted ? themeStyles.emojiItemHighlighted : {}, + isFocused || isHovered || isHighlighted ? themeStyles.emojiItemHighlighted : {}, Browser.isMobile() && StyleUtils.getButtonBackgroundColorStyle(getButtonState(false, pressed)), themeStyles.emojiItem, ]} diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/MentionReportRenderer/index.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/MentionReportRenderer/index.tsx index 3ab907dc767d..94a46d861dde 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/MentionReportRenderer/index.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/MentionReportRenderer/index.tsx @@ -10,10 +10,14 @@ import Text from '@components/Text'; import useCurrentReportID from '@hooks/useCurrentReportID'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; -import Navigation from '@navigation/Navigation'; +import getTopmostCentralPaneRoute from '@libs/Navigation/getTopmostCentralPaneRoute'; +import type {RootStackParamList, State} from '@libs/Navigation/types'; +import Navigation, {navigationRef} from '@navigation/Navigation'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; +import type {Route} from '@src/ROUTES'; +import SCREENS from '@src/SCREENS'; import type {Report} from '@src/types/onyx'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import MentionReportContext from './MentionReportContext'; @@ -69,7 +73,12 @@ function MentionReportRenderer({style, tnode, TDefaultRenderer, ...defaultRender } const {reportID, mentionDisplayText} = mentionDetails; - const navigationRoute = reportID ? ROUTES.REPORT_WITH_ID.getRoute(reportID) : undefined; + let navigationRoute: Route | undefined = reportID ? ROUTES.REPORT_WITH_ID.getRoute(reportID) : undefined; + const topmostCentralPaneRoute = getTopmostCentralPaneRoute(navigationRef.getRootState() as State); + const backTo = Navigation.getActiveRoute(); + if (topmostCentralPaneRoute?.name === SCREENS.SEARCH.CENTRAL_PANE) { + navigationRoute = reportID ? ROUTES.SEARCH_REPORT.getRoute({reportID, backTo}) : undefined; + } const isCurrentRoomMention = reportID === currentReportIDValue; const flattenStyle = StyleSheet.flatten(style as TextStyle); diff --git a/src/components/Icon/Expensicons.ts b/src/components/Icon/Expensicons.ts index 90f0e0d8a151..fa531ce34adf 100644 --- a/src/components/Icon/Expensicons.ts +++ b/src/components/Icon/Expensicons.ts @@ -23,6 +23,7 @@ import Bed from '@assets/images/bed.svg'; import Bell from '@assets/images/bell.svg'; import BellSlash from '@assets/images/bellSlash.svg'; import Bill from '@assets/images/bill.svg'; +import Binoculars from '@assets/images/binoculars.svg'; import Bolt from '@assets/images/bolt.svg'; import Bookmark from '@assets/images/bookmark.svg'; import Box from '@assets/images/box.svg'; @@ -223,6 +224,7 @@ export { Bill, Bell, BellSlash, + Binoculars, Bolt, Box, Briefcase, diff --git a/src/components/InteractiveStepWrapper.tsx b/src/components/InteractiveStepWrapper.tsx index 290ad628f9cf..ec0d2da02108 100644 --- a/src/components/InteractiveStepWrapper.tsx +++ b/src/components/InteractiveStepWrapper.tsx @@ -20,6 +20,9 @@ type InteractiveStepWrapperProps = { // Title of the back button header headerTitle: string; + // Subtitle of the back button header + headerSubtitle?: string; + // Index of the highlighted step startStepIndex?: number; @@ -48,6 +51,7 @@ function InteractiveStepWrapper( wrapperID, handleBackButtonPress, headerTitle, + headerSubtitle, startStepIndex, stepNames, shouldEnableMaxHeight, @@ -72,6 +76,7 @@ function InteractiveStepWrapper( > diff --git a/src/components/LoadingBar.tsx b/src/components/LoadingBar.tsx new file mode 100644 index 000000000000..163ffe2aa66b --- /dev/null +++ b/src/components/LoadingBar.tsx @@ -0,0 +1,85 @@ +import React, {useEffect} from 'react'; +import Animated, {cancelAnimation, Easing, runOnJS, useAnimatedStyle, useSharedValue, withDelay, withRepeat, withSequence, withTiming} from 'react-native-reanimated'; +import useThemeStyles from '@hooks/useThemeStyles'; +import CONST from '@src/CONST'; + +type LoadingBarProps = { + // Whether or not to show the loading bar + shouldShow: boolean; +}; + +function LoadingBar({shouldShow}: LoadingBarProps) { + const left = useSharedValue(0); + const width = useSharedValue(0); + const opacity = useSharedValue(0); + const isVisible = useSharedValue(false); + const styles = useThemeStyles(); + + useEffect(() => { + if (shouldShow) { + // eslint-disable-next-line react-compiler/react-compiler + isVisible.value = true; + left.value = 0; + width.value = 0; + opacity.value = withTiming(1, {duration: CONST.ANIMATED_PROGRESS_BAR_OPACITY_DURATION}); + left.value = withDelay( + CONST.ANIMATED_PROGRESS_BAR_DELAY, + withRepeat( + withSequence( + withTiming(0, {duration: 0}), + withTiming(0, {duration: CONST.ANIMATED_PROGRESS_BAR_DURATION, easing: Easing.bezier(0.65, 0, 0.35, 1)}), + withTiming(100, {duration: CONST.ANIMATED_PROGRESS_BAR_DURATION, easing: Easing.bezier(0.65, 0, 0.35, 1)}), + ), + -1, + false, + ), + ); + + width.value = withDelay( + CONST.ANIMATED_PROGRESS_BAR_DELAY, + withRepeat( + withSequence( + withTiming(0, {duration: 0}), + withTiming(100, {duration: CONST.ANIMATED_PROGRESS_BAR_DURATION, easing: Easing.bezier(0.65, 0, 0.35, 1)}), + withTiming(0, {duration: CONST.ANIMATED_PROGRESS_BAR_DURATION, easing: Easing.bezier(0.65, 0, 0.35, 1)}), + ), + -1, + false, + ), + ); + } else if (isVisible.value) { + opacity.value = withTiming(0, {duration: CONST.ANIMATED_PROGRESS_BAR_OPACITY_DURATION}, () => { + runOnJS(() => { + isVisible.value = false; + cancelAnimation(left); + cancelAnimation(width); + }); + }); + } + // we want to update only when shouldShow changes + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps + }, [shouldShow]); + + const animatedIndicatorStyle = useAnimatedStyle(() => { + return { + left: `${left.value}%`, + width: `${width.value}%`, + }; + }); + + const animatedContainerStyle = useAnimatedStyle(() => { + return { + opacity: opacity.value, + }; + }); + + return ( + + {isVisible.value ? : null} + + ); +} + +LoadingBar.displayName = 'ProgressBar'; + +export default LoadingBar; diff --git a/src/components/MenuItem.tsx b/src/components/MenuItem.tsx index 303a51d064d9..7fb5533fd172 100644 --- a/src/components/MenuItem.tsx +++ b/src/components/MenuItem.tsx @@ -251,6 +251,9 @@ type MenuItemBaseProps = { /** Should we remove the background color of the menu item */ shouldRemoveBackground?: boolean; + /** Should we remove the hover background color of the menu item */ + shouldRemoveHoverBackground?: boolean; + /** Should we use default cursor for disabled content */ shouldUseDefaultCursorWhenDisabled?: boolean; @@ -411,6 +414,7 @@ function MenuItem( shouldEscapeText = undefined, shouldGreyOutWhenDisabled = true, shouldRemoveBackground = false, + shouldRemoveHoverBackground = false, shouldUseDefaultCursorWhenDisabled = false, shouldShowLoadingSpinnerIcon = false, isAnonymousAction = false, @@ -594,7 +598,7 @@ function MenuItem( StyleUtils.getButtonBackgroundColorStyle(getButtonState(focused || isHovered, pressed, success, disabled, interactive), true), ...(Array.isArray(wrapperStyle) ? wrapperStyle : [wrapperStyle]), shouldGreyOutWhenDisabled && disabled && styles.buttonOpacityDisabled, - isHovered && interactive && !focused && !pressed && !shouldRemoveBackground && styles.hoveredComponentBG, + isHovered && interactive && !focused && !pressed && !shouldRemoveBackground && !shouldRemoveHoverBackground && styles.hoveredComponentBG, ] as StyleProp } disabledStyle={shouldUseDefaultCursorWhenDisabled && [styles.cursorDefault]} diff --git a/src/components/Modal/BaseModal.tsx b/src/components/Modal/BaseModal.tsx index 85a2298f63d6..39396795c557 100644 --- a/src/components/Modal/BaseModal.tsx +++ b/src/components/Modal/BaseModal.tsx @@ -193,7 +193,7 @@ function BaseModal( safeAreaPaddingRight, shouldAddBottomSafeAreaMargin, shouldAddTopSafeAreaMargin, - shouldAddBottomSafeAreaPadding: !keyboardStateContextValue?.isKeyboardShown && shouldAddBottomSafeAreaPadding, + shouldAddBottomSafeAreaPadding: (!avoidKeyboard || !keyboardStateContextValue?.isKeyboardShown) && shouldAddBottomSafeAreaPadding, shouldAddTopSafeAreaPadding, modalContainerStyleMarginTop: modalContainerStyle.marginTop, modalContainerStyleMarginBottom: modalContainerStyle.marginBottom, diff --git a/src/components/MoneyRequestAmountInput.tsx b/src/components/MoneyRequestAmountInput.tsx index 915b0eb38505..9ef33900bb00 100644 --- a/src/components/MoneyRequestAmountInput.tsx +++ b/src/components/MoneyRequestAmountInput.tsx @@ -1,6 +1,6 @@ import type {ForwardedRef} from 'react'; -import React, {useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react'; -import type {NativeSyntheticEvent, StyleProp, TextInputSelectionChangeEventData, TextStyle, ViewStyle} from 'react-native'; +import React, {useCallback, useEffect, useImperativeHandle, useRef, useState} from 'react'; +import type {NativeSyntheticEvent, StyleProp, TextStyle, ViewStyle} from 'react-native'; import useLocalize from '@hooks/useLocalize'; import {useMouseContext} from '@hooks/useMouseContext'; import * as Browser from '@libs/Browser'; @@ -274,7 +274,6 @@ function MoneyRequestAmountInput( }); }, [amount, currency, onFormatAmount, formatAmountOnBlur, maxLength]); - const regex = useMemo(() => MoneyRequestUtils.amountRegex(decimals), [decimals]); const formattedAmount = MoneyRequestUtils.replaceAllDigits(currentAmount, toLocaleDigit); const {setMouseDown, setMouseUp} = useMouseContext(); @@ -308,7 +307,7 @@ function MoneyRequestAmountInput( }} selectedCurrencyCode={currency} selection={selection} - onSelectionChange={(e: NativeSyntheticEvent) => { + onSelectionChange={(selectionStart, selectionEnd) => { if (shouldIgnoreSelectionWhenUpdatedManually && willSelectionBeUpdatedManually.current) { willSelectionBeUpdatedManually.current = false; return; @@ -320,8 +319,8 @@ function MoneyRequestAmountInput( // When the amount is updated in setNewAmount on iOS, in onSelectionChange formattedAmount stores the value before the update. Using amountRef allows us to read the updated value const maxSelection = amountRef.current?.length ?? formattedAmount.length; amountRef.current = undefined; - const start = Math.min(e.nativeEvent.selection.start, maxSelection); - const end = Math.min(e.nativeEvent.selection.end, maxSelection); + const start = Math.min(selectionStart, maxSelection); + const end = Math.min(selectionEnd, maxSelection); setSelection({start, end}); }} onKeyPress={textInputKeyPress} @@ -338,7 +337,6 @@ function MoneyRequestAmountInput( onMouseDown={handleMouseDown} onMouseUp={handleMouseUp} contentWidth={contentWidth} - regex={regex} /> ); } diff --git a/src/components/MoneyRequestConfirmationList.tsx b/src/components/MoneyRequestConfirmationList.tsx index e70a8cec4775..01fd15c52bb4 100755 --- a/src/components/MoneyRequestConfirmationList.tsx +++ b/src/components/MoneyRequestConfirmationList.tsx @@ -2,8 +2,8 @@ import {useFocusEffect, useIsFocused} from '@react-navigation/native'; import lodashIsEqual from 'lodash/isEqual'; import React, {memo, useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {InteractionManager, View} from 'react-native'; -import {withOnyx} from 'react-native-onyx'; import type {OnyxEntry} from 'react-native-onyx'; +import {useOnyx} from 'react-native-onyx'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useDebouncedState from '@hooks/useDebouncedState'; import useLocalize from '@hooks/useLocalize'; @@ -14,7 +14,6 @@ import useThemeStyles from '@hooks/useThemeStyles'; import blurActiveElement from '@libs/Accessibility/blurActiveElement'; import * as CurrencyUtils from '@libs/CurrencyUtils'; import DistanceRequestUtils from '@libs/DistanceRequestUtils'; -import type {MileageRate} from '@libs/DistanceRequestUtils'; import * as IOUUtils from '@libs/IOUUtils'; import Log from '@libs/Log'; import * as MoneyRequestUtils from '@libs/MoneyRequestUtils'; @@ -49,33 +48,7 @@ import UserListItem from './SelectionList/UserListItem'; import SettlementButton from './SettlementButton'; import Text from './Text'; -type MoneyRequestConfirmationListOnyxProps = { - /** Collection of categories attached to a policy */ - policyCategories: OnyxEntry; - - /** Collection of draft categories attached to a policy */ - policyCategoriesDraft: OnyxEntry; - - /** Collection of tags attached to a policy */ - policyTags: OnyxEntry; - - /** The policy of the report */ - policy: OnyxEntry; - - /** The draft policy of the report */ - policyDraft: OnyxEntry; - - /** Mileage rate default for the policy */ - defaultMileageRate: OnyxEntry; - - /** Last selected distance rates */ - lastSelectedDistanceRates: OnyxEntry>; - - /** List of currencies */ - currencyList: OnyxEntry; -}; - -type MoneyRequestConfirmationListProps = MoneyRequestConfirmationListOnyxProps & { +type MoneyRequestConfirmationListProps = { /** Callback to inform parent modal of success */ onConfirm?: (selectedParticipants: Participant[]) => void; @@ -178,23 +151,18 @@ function MoneyRequestConfirmationList({ onConfirm, iouType = CONST.IOU.TYPE.SUBMIT, iouAmount, - policyCategories: policyCategoriesReal, - policyCategoriesDraft, isDistanceRequest = false, - policy: policyReal, - policyDraft, isPolicyExpenseChat = false, iouCategory = '', shouldShowSmartScanFields = true, isEditingSplitBill, - policyTags, iouCurrencyCode, iouMerchant, selectedParticipants: selectedParticipantsProp, payeePersonalDetails: payeePersonalDetailsProp, isReadOnly = false, bankAccountRoute = '', - policyID = '', + policyID, reportID = '', receiptPath = '', iouAttendees, @@ -205,14 +173,22 @@ function MoneyRequestConfirmationList({ onToggleBillable, hasSmartScanFailed, reportActionID, - defaultMileageRate, - lastSelectedDistanceRates, action = CONST.IOU.ACTION.CREATE, - currencyList, shouldDisplayReceipt = false, shouldPlaySound = true, isConfirmed, }: MoneyRequestConfirmationListProps) { + const [policyCategoriesReal] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID ?? '-1'}`); + const [policyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID ?? '-1'}`); + const [policyReal] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID ?? '-1'}`); + const [policyDraft] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_DRAFTS}${policyID ?? '-1'}`); + const [defaultMileageRate] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_DRAFTS}${policyID ?? '-1'}`, { + selector: (selectedPolicy) => DistanceRequestUtils.getDefaultMileageRate(selectedPolicy), + }); + const [policyCategoriesDraft] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES_DRAFT}${policyID ?? '-1'}`); + const [lastSelectedDistanceRates] = useOnyx(ONYXKEYS.NVP_LAST_SELECTED_DISTANCE_RATES); + const [currencyList] = useOnyx(ONYXKEYS.CURRENCY_LIST); + const policy = policyReal ?? policyDraft; const policyCategories = policyCategoriesReal ?? policyCategoriesDraft; @@ -972,67 +948,36 @@ function MoneyRequestConfirmationList({ MoneyRequestConfirmationList.displayName = 'MoneyRequestConfirmationList'; -export default withOnyx({ - policyCategories: { - key: ({policyID}) => `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, - }, - policyCategoriesDraft: { - key: ({policyID}) => `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES_DRAFT}${policyID}`, - }, - policyTags: { - key: ({policyID}) => `${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`, - }, - defaultMileageRate: { - key: ({policyID}) => `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, - selector: DistanceRequestUtils.getDefaultMileageRate, - }, - policy: { - key: ({policyID}) => `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, - }, - policyDraft: { - key: ({policyID}) => `${ONYXKEYS.COLLECTION.POLICY_DRAFTS}${policyID}`, - }, - lastSelectedDistanceRates: { - key: ONYXKEYS.NVP_LAST_SELECTED_DISTANCE_RATES, - }, - currencyList: { - key: ONYXKEYS.CURRENCY_LIST, - }, -})( - memo( - MoneyRequestConfirmationList, - (prevProps, nextProps) => - lodashIsEqual(prevProps.transaction, nextProps.transaction) && - prevProps.onSendMoney === nextProps.onSendMoney && - prevProps.onConfirm === nextProps.onConfirm && - prevProps.iouType === nextProps.iouType && - prevProps.iouAmount === nextProps.iouAmount && - prevProps.isDistanceRequest === nextProps.isDistanceRequest && - prevProps.isPolicyExpenseChat === nextProps.isPolicyExpenseChat && - prevProps.iouCategory === nextProps.iouCategory && - prevProps.shouldShowSmartScanFields === nextProps.shouldShowSmartScanFields && - prevProps.isEditingSplitBill === nextProps.isEditingSplitBill && - prevProps.iouCurrencyCode === nextProps.iouCurrencyCode && - prevProps.iouMerchant === nextProps.iouMerchant && - lodashIsEqual(prevProps.selectedParticipants, nextProps.selectedParticipants) && - lodashIsEqual(prevProps.payeePersonalDetails, nextProps.payeePersonalDetails) && - prevProps.isReadOnly === nextProps.isReadOnly && - prevProps.bankAccountRoute === nextProps.bankAccountRoute && - prevProps.policyID === nextProps.policyID && - prevProps.reportID === nextProps.reportID && - prevProps.receiptPath === nextProps.receiptPath && - prevProps.iouAttendees === nextProps.iouAttendees && - prevProps.iouComment === nextProps.iouComment && - prevProps.receiptFilename === nextProps.receiptFilename && - prevProps.iouCreated === nextProps.iouCreated && - prevProps.iouIsBillable === nextProps.iouIsBillable && - prevProps.onToggleBillable === nextProps.onToggleBillable && - prevProps.hasSmartScanFailed === nextProps.hasSmartScanFailed && - prevProps.reportActionID === nextProps.reportActionID && - lodashIsEqual(prevProps.defaultMileageRate, nextProps.defaultMileageRate) && - lodashIsEqual(prevProps.lastSelectedDistanceRates, nextProps.lastSelectedDistanceRates) && - lodashIsEqual(prevProps.action, nextProps.action) && - lodashIsEqual(prevProps.currencyList, nextProps.currencyList) && - prevProps.shouldDisplayReceipt === nextProps.shouldDisplayReceipt, - ), +export default memo( + MoneyRequestConfirmationList, + (prevProps, nextProps) => + lodashIsEqual(prevProps.transaction, nextProps.transaction) && + prevProps.onSendMoney === nextProps.onSendMoney && + prevProps.onConfirm === nextProps.onConfirm && + prevProps.iouType === nextProps.iouType && + prevProps.iouAmount === nextProps.iouAmount && + prevProps.isDistanceRequest === nextProps.isDistanceRequest && + prevProps.isPolicyExpenseChat === nextProps.isPolicyExpenseChat && + prevProps.iouCategory === nextProps.iouCategory && + prevProps.shouldShowSmartScanFields === nextProps.shouldShowSmartScanFields && + prevProps.isEditingSplitBill === nextProps.isEditingSplitBill && + prevProps.iouCurrencyCode === nextProps.iouCurrencyCode && + prevProps.iouMerchant === nextProps.iouMerchant && + lodashIsEqual(prevProps.selectedParticipants, nextProps.selectedParticipants) && + lodashIsEqual(prevProps.payeePersonalDetails, nextProps.payeePersonalDetails) && + prevProps.isReadOnly === nextProps.isReadOnly && + prevProps.bankAccountRoute === nextProps.bankAccountRoute && + prevProps.policyID === nextProps.policyID && + prevProps.reportID === nextProps.reportID && + prevProps.receiptPath === nextProps.receiptPath && + prevProps.iouAttendees === nextProps.iouAttendees && + prevProps.iouComment === nextProps.iouComment && + prevProps.receiptFilename === nextProps.receiptFilename && + prevProps.iouCreated === nextProps.iouCreated && + prevProps.iouIsBillable === nextProps.iouIsBillable && + prevProps.onToggleBillable === nextProps.onToggleBillable && + prevProps.hasSmartScanFailed === nextProps.hasSmartScanFailed && + prevProps.reportActionID === nextProps.reportActionID && + lodashIsEqual(prevProps.action, nextProps.action) && + prevProps.shouldDisplayReceipt === nextProps.shouldDisplayReceipt, ); diff --git a/src/components/PopoverMenu.tsx b/src/components/PopoverMenu.tsx index 21d0aa516d86..2f5537be6145 100644 --- a/src/components/PopoverMenu.tsx +++ b/src/components/PopoverMenu.tsx @@ -8,6 +8,7 @@ import type {ModalProps} from 'react-native-modal'; import useArrowKeyFocusManager from '@hooks/useArrowKeyFocusManager'; import useKeyboardShortcut from '@hooks/useKeyboardShortcut'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; @@ -168,6 +169,7 @@ function PopoverMenu({ }: PopoverMenuProps) { const styles = useThemeStyles(); const theme = useTheme(); + const StyleUtils = useStyleUtils(); // We need to use isSmallScreenWidth instead of shouldUseNarrowLayout to apply correct popover styles // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth const {isSmallScreenWidth} = useResponsiveLayout(); @@ -262,7 +264,8 @@ function PopoverMenu({ } setFocusedIndex(menuIndex); }} - style={{backgroundColor: item.isSelected ? theme.activeComponentBG : undefined}} + wrapperStyle={StyleUtils.getItemBackgroundColorStyle(!!item.isSelected, focusedIndex === menuIndex, theme.activeComponentBG, theme.hoverComponentBG)} + shouldRemoveHoverBackground={item.isSelected} titleStyle={StyleSheet.flatten([styles.flex1, item.titleStyle])} // Spread other props dynamically {...menuItemProps} diff --git a/src/components/SelectionList/BaseListItem.tsx b/src/components/SelectionList/BaseListItem.tsx index dc216b51e291..15a82e327b9a 100644 --- a/src/components/SelectionList/BaseListItem.tsx +++ b/src/components/SelectionList/BaseListItem.tsx @@ -6,6 +6,7 @@ import OfflineWithFeedback from '@components/OfflineWithFeedback'; import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; import useHover from '@hooks/useHover'; import {useMouseContext} from '@hooks/useMouseContext'; +import useStyleUtils from '@hooks/useStyleUtils'; import useSyncFocus from '@hooks/useSyncFocus'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -38,6 +39,7 @@ function BaseListItem({ }: BaseListItemProps) { const theme = useTheme(); const styles = useThemeStyles(); + const StyleUtils = useStyleUtils(); const {hovered, bind} = useHover(); const {isMouseDownOnInput, setMouseUp} = useMouseContext(); @@ -96,13 +98,13 @@ function BaseListItem({ dataSet={{[CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT]: true, [CONST.INNER_BOX_SHADOW_ELEMENT]: true}} onMouseDown={(e) => e.preventDefault()} id={keyForList ?? ''} - style={pressableStyle} + style={[pressableStyle, isFocused && StyleUtils.getItemBackgroundColorStyle(!!item.isSelected, !!isFocused, theme.activeComponentBG, theme.hoverComponentBG)]} onFocus={onFocus} onMouseLeave={handleMouseLeave} tabIndex={item.tabIndex} wrapperStyle={pressableWrapperStyle} > - + {typeof children === 'function' ? children(hovered) : children} {!canSelectMultiple && !!item.isSelected && !rightHandSideComponent && ( diff --git a/src/components/SelectionList/CardListItem.tsx b/src/components/SelectionList/CardListItem.tsx index 7f40d3165501..0e887d1d30db 100644 --- a/src/components/SelectionList/CardListItem.tsx +++ b/src/components/SelectionList/CardListItem.tsx @@ -46,7 +46,7 @@ function CardListItem({ return ( ({ return ( ({ ({ return ( ({ // Removing background style because they are added to the parent OpacityView via animatedHighlightStyle styles.bgTransparent, item.isSelected && styles.activeComponentBG, - isFocused && styles.sidebarLinkActive, styles.mh0, ]; diff --git a/src/components/SelectionList/Search/SearchQueryListItem.tsx b/src/components/SelectionList/Search/SearchQueryListItem.tsx index f1636be0d88c..bba574fa3ac7 100644 --- a/src/components/SelectionList/Search/SearchQueryListItem.tsx +++ b/src/components/SelectionList/Search/SearchQueryListItem.tsx @@ -32,7 +32,7 @@ function SearchQueryListItem({item, isFocused, showTooltip, onSelectRow, onFocus return ( ({ // Removing background style because they are added to the parent OpacityView via animatedHighlightStyle styles.bgTransparent, item.isSelected && styles.activeComponentBG, - isFocused && styles.sidebarLinkActive, styles.mh0, ]; diff --git a/src/components/SelectionList/SelectableListItem.tsx b/src/components/SelectionList/SelectableListItem.tsx index b1b242737623..e3c2a36ea7ed 100644 --- a/src/components/SelectionList/SelectableListItem.tsx +++ b/src/components/SelectionList/SelectableListItem.tsx @@ -32,7 +32,7 @@ function SelectableListItem({ return ( ({ return ( ({ rightHandSideComponent={rightHandSideComponent} errors={item.errors} pendingAction={item.pendingAction} - pressableStyle={[isFocused && styles.sidebarLinkActive, pressableStyle]} + pressableStyle={pressableStyle} FooterComponent={ item.invitedSecondaryLogin ? ( diff --git a/src/components/TabSelector/getOpacity/index.native.ts b/src/components/TabSelector/getOpacity/index.native.ts index 0da5455214c9..a59d32c2db6e 100644 --- a/src/components/TabSelector/getOpacity/index.native.ts +++ b/src/components/TabSelector/getOpacity/index.native.ts @@ -1,6 +1,6 @@ import type GetOpacity from './types'; -const getOpacity: GetOpacity = ({routesLength, tabIndex, active, affectedTabs, position, isActive}) => { +const getOpacity: GetOpacity = ({routesLength, tabIndex, active, affectedTabs, position}) => { const activeValue = active ? 1 : 0; const inactiveValue = active ? 0 : 1; @@ -9,7 +9,7 @@ const getOpacity: GetOpacity = ({routesLength, tabIndex, active, affectedTabs, p return position?.interpolate({ inputRange, - outputRange: inputRange.map((i) => (affectedTabs.includes(tabIndex) && i === tabIndex && isActive ? activeValue : inactiveValue)), + outputRange: inputRange.map((i) => (affectedTabs.includes(tabIndex) && i === tabIndex ? activeValue : inactiveValue)), }); } return activeValue; diff --git a/src/components/TextInputWithCurrencySymbol/BaseTextInputWithCurrencySymbol.tsx b/src/components/TextInputWithCurrencySymbol/BaseTextInputWithCurrencySymbol.tsx index 3e7c5f0bc414..4c30b048c1af 100644 --- a/src/components/TextInputWithCurrencySymbol/BaseTextInputWithCurrencySymbol.tsx +++ b/src/components/TextInputWithCurrencySymbol/BaseTextInputWithCurrencySymbol.tsx @@ -7,7 +7,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import * as CurrencyUtils from '@libs/CurrencyUtils'; import * as MoneyRequestUtils from '@libs/MoneyRequestUtils'; import type {BaseTextInputRef} from '@src/components/TextInput/BaseTextInput/types'; -import type TextInputWithCurrencySymbolProps from './types'; +import type BaseTextInputWithCurrencySymbolProps from './types'; function BaseTextInputWithCurrencySymbol( { @@ -24,7 +24,7 @@ function BaseTextInputWithCurrencySymbol( extraSymbol, style, ...rest - }: TextInputWithCurrencySymbolProps, + }: BaseTextInputWithCurrencySymbolProps, ref: React.ForwardedRef, ) { const {fromLocaleDigit} = useLocalize(); diff --git a/src/components/TextInputWithCurrencySymbol/index.android.tsx b/src/components/TextInputWithCurrencySymbol/index.android.tsx index cbd822d07cf8..f7794c5822ff 100644 --- a/src/components/TextInputWithCurrencySymbol/index.android.tsx +++ b/src/components/TextInputWithCurrencySymbol/index.android.tsx @@ -2,7 +2,7 @@ import React, {useEffect, useState} from 'react'; import type {NativeSyntheticEvent, TextInputSelectionChangeEventData} from 'react-native'; import type {BaseTextInputRef} from '@src/components/TextInput/BaseTextInput/types'; import BaseTextInputWithCurrencySymbol from './BaseTextInputWithCurrencySymbol'; -import type TextInputWithCurrencySymbolProps from './types'; +import type {TextInputWithCurrencySymbolProps} from './types'; function TextInputWithCurrencySymbol({onSelectionChange = () => {}, ...props}: TextInputWithCurrencySymbolProps, ref: React.ForwardedRef) { const [skipNextSelectionChange, setSkipNextSelectionChange] = useState(false); @@ -21,7 +21,7 @@ function TextInputWithCurrencySymbol({onSelectionChange = () => {}, ...props}: T setSkipNextSelectionChange(false); return; } - onSelectionChange(event); + onSelectionChange(event.nativeEvent.selection.start, event.nativeEvent.selection.end); }} /> ); diff --git a/src/components/TextInputWithCurrencySymbol/index.tsx b/src/components/TextInputWithCurrencySymbol/index.tsx index b6230061bb6c..9e8333d9db23 100644 --- a/src/components/TextInputWithCurrencySymbol/index.tsx +++ b/src/components/TextInputWithCurrencySymbol/index.tsx @@ -1,14 +1,18 @@ import React from 'react'; +import type {NativeSyntheticEvent, TextInputSelectionChangeEventData} from 'react-native'; import type {BaseTextInputRef} from '@src/components/TextInput/BaseTextInput/types'; import BaseTextInputWithCurrencySymbol from './BaseTextInputWithCurrencySymbol'; -import type TextInputWithCurrencySymbolProps from './types'; +import type {TextInputWithCurrencySymbolProps} from './types'; -function TextInputWithCurrencySymbol(props: TextInputWithCurrencySymbolProps, ref: React.ForwardedRef) { +function TextInputWithCurrencySymbol({onSelectionChange = () => {}, ...props}: TextInputWithCurrencySymbolProps, ref: React.ForwardedRef) { return ( ) => { + onSelectionChange(event.nativeEvent.selection.start, event.nativeEvent.selection.end); + }} /> ); } diff --git a/src/components/TextInputWithCurrencySymbol/index.web.tsx b/src/components/TextInputWithCurrencySymbol/index.web.tsx new file mode 100644 index 000000000000..f86b764f79cc --- /dev/null +++ b/src/components/TextInputWithCurrencySymbol/index.web.tsx @@ -0,0 +1,42 @@ +import React, {useRef} from 'react'; +import type {NativeSyntheticEvent, TextInputSelectionChangeEventData} from 'react-native'; +import BaseTextInputWithCurrencySymbol from './BaseTextInputWithCurrencySymbol'; +import type {TextInputWithCurrencySymbolProps} from './types'; + +function TextInputWithCurrencySymbol({onSelectionChange = () => {}, ...props}: TextInputWithCurrencySymbolProps, ref: React.ForwardedRef) { + const textInputRef = useRef(null); + + return ( + { + textInputRef.current = element as HTMLFormElement; + + if (!ref) { + return; + } + + if (typeof ref === 'function') { + ref(element as HTMLFormElement); + return; + } + + // eslint-disable-next-line no-param-reassign + ref.current = element as HTMLFormElement; + }} + onSelectionChange={(event: NativeSyntheticEvent) => { + onSelectionChange(event.nativeEvent.selection.start, event.nativeEvent.selection.end); + }} + onPress={() => { + const selectionStart = (textInputRef.current?.selectionStart as number) ?? 0; + const selectionEnd = (textInputRef.current?.selectionEnd as number) ?? 0; + onSelectionChange(selectionStart, selectionEnd); + }} + /> + ); +} + +TextInputWithCurrencySymbol.displayName = 'TextInputWithCurrencySymbol'; + +export default React.forwardRef(TextInputWithCurrencySymbol); diff --git a/src/components/TextInputWithCurrencySymbol/types.ts b/src/components/TextInputWithCurrencySymbol/types.ts index 1d744e974be3..401af75b16cd 100644 --- a/src/components/TextInputWithCurrencySymbol/types.ts +++ b/src/components/TextInputWithCurrencySymbol/types.ts @@ -2,7 +2,7 @@ import type {NativeSyntheticEvent, StyleProp, TextInputFocusEventData, TextInput import type {TextSelection} from '@components/Composer/types'; import type {BaseTextInputProps} from '@components/TextInput/BaseTextInput/types'; -type TextInputWithCurrencySymbolProps = { +type BaseTextInputWithCurrencySymbolProps = { /** Formatted amount in local currency */ formattedAmount: string; @@ -77,6 +77,12 @@ type TextInputWithCurrencySymbolProps = { /** Hide the focus styles on TextInput */ hideFocusedState?: boolean; -} & Pick; +} & Pick; -export default TextInputWithCurrencySymbolProps; +type TextInputWithCurrencySymbolProps = Omit & { + onSelectionChange?: (start: number, end: number) => void; +}; + +export type {TextInputWithCurrencySymbolProps}; + +export default BaseTextInputWithCurrencySymbolProps; diff --git a/src/components/ValidateCodeActionModal/ValidateCodeForm/BaseValidateCodeForm.tsx b/src/components/ValidateCodeActionModal/ValidateCodeForm/BaseValidateCodeForm.tsx index cc2a7314f570..392e4b9176e6 100644 --- a/src/components/ValidateCodeActionModal/ValidateCodeForm/BaseValidateCodeForm.tsx +++ b/src/components/ValidateCodeActionModal/ValidateCodeForm/BaseValidateCodeForm.tsx @@ -92,7 +92,6 @@ function BaseValidateCodeForm({ const shouldDisableResendValidateCode = !!isOffline || account?.isLoading; const focusTimeoutRef = useRef(null); const [timeRemaining, setTimeRemaining] = useState(CONST.REQUEST_CODE_DELAY as number); - const [isResent, setIsResent] = useState(false); const timerRef = useRef(); @@ -155,10 +154,6 @@ function BaseValidateCodeForm({ * Request a validate code / magic code be sent to verify this contact method */ const resendValidateCode = () => { - if (hasMagicCodeBeenSent && !isResent) { - return; - } - sendValidateCode(); inputValidateCodeRef.current?.clear(); setTimeRemaining(CONST.REQUEST_CODE_DELAY); @@ -229,10 +224,7 @@ function BaseValidateCodeForm({ { - resendValidateCode(); - setIsResent(true); - }} + onPress={resendValidateCode} underlayColor={theme.componentBG} hoverDimmingValue={1} pressDimmingValue={0.2} diff --git a/src/languages/en.ts b/src/languages/en.ts index 03bbaf8ca8ab..21f220b747f2 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -3106,14 +3106,17 @@ const translations = { processorLabel: 'Processor ID', bankLabel: 'Financial institution (bank) ID', companyLabel: 'Company ID', + helpLabel: 'Where do I find these IDs?', }, gl1025: { title: `What's the Amex delivery file name?`, fileNameLabel: 'Delivery file name', + helpLabel: 'Where do I find the delivery file name?', }, cdf: { title: `What's the Mastercard distribution ID?`, distributionLabel: 'Distribution ID', + helpLabel: 'Where do I find the distribution ID?', }, }, amexCorporate: 'Select this if the front of your cards say “Corporate”', @@ -3320,7 +3323,7 @@ const translations = { cardNumber: 'Card number', cardholder: 'Cardholder', cardName: 'Card name', - integrationExport: ({integration, type}: IntegrationExportParams) => `${integration} ${type} export`, + integrationExport: ({integration, type}: IntegrationExportParams) => `${integration} ${type?.toLowerCase()} export`, integrationExportTitleFirstPart: ({integration}: IntegrationExportParams) => `Choose the ${integration} account where transactions should be exported. Select a different`, integrationExportTitleLinkPart: 'export option', integrationExportTitleSecondPart: 'to change the available accounts.', @@ -4753,12 +4756,12 @@ const translations = { }, modifiedDate: 'Date differs from scanned receipt', nonExpensiworksExpense: 'Non-Expensiworks expense', - overAutoApprovalLimit: ({formattedLimit}: ViolationsOverLimitParams) => `Expense exceeds auto approval limit of ${formattedLimit}`, + overAutoApprovalLimit: ({formattedLimit}: ViolationsOverLimitParams) => `Expense exceeds auto-approval limit of ${formattedLimit}`, overCategoryLimit: ({formattedLimit}: ViolationsOverCategoryLimitParams) => `Amount over ${formattedLimit}/person category limit`, overLimit: ({formattedLimit}: ViolationsOverLimitParams) => `Amount over ${formattedLimit}/person limit`, overLimitAttendee: ({formattedLimit}: ViolationsOverLimitParams) => `Amount over ${formattedLimit}/person limit`, perDayLimit: ({formattedLimit}: ViolationsPerDayLimitParams) => `Amount over daily ${formattedLimit}/person category limit`, - receiptNotSmartScanned: 'Receipt not verified. Please confirm accuracy.', + receiptNotSmartScanned: 'Receipt scan incomplete. Please verify details manually.', receiptRequired: ({formattedLimit, category}: ViolationsReceiptRequiredParams) => { let message = 'Receipt required'; if (formattedLimit ?? category) { @@ -5188,6 +5191,10 @@ const translations = { emptySearchView: { takeATour: 'Take a tour', }, + tour: { + takeATwoMinuteTour: 'Take a 2-minute tour', + exploreExpensify: 'Explore everything Expensify has to offer', + }, }; export default translations satisfies TranslationDeepObject; diff --git a/src/languages/es.ts b/src/languages/es.ts index 96921bd40388..ac06741f467e 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -3145,14 +3145,17 @@ const translations = { processorLabel: 'ID del procesador', bankLabel: 'Identificación de la institución financiera (banco)', companyLabel: 'Empresa ID', + helpLabel: '¿Dónde encuentro estos IDs?', }, gl1025: { title: `¿Cuál es el nombre del archivo de entrega de Amex?`, fileNameLabel: 'Nombre del archivo de entrega', + helpLabel: '¿Dónde encuentro el nombre del archivo de entrega?', }, cdf: { title: `¿Cuál es el identificador de distribución de Mastercard?`, distributionLabel: 'ID de distribución', + helpLabel: '¿Dónde encuentro el ID de distribución?', }, }, amexCorporate: 'Seleccione esto si el frente de sus tarjetas dice “Corporativa”', @@ -3361,7 +3364,7 @@ const translations = { cardNumber: 'Número de la tarjeta', cardholder: 'Titular de la tarjeta', cardName: 'Nombre de la tarjeta', - integrationExport: ({integration, type}: IntegrationExportParams) => `Exportación a ${integration} ${type}`, + integrationExport: ({integration, type}: IntegrationExportParams) => `Exportación a ${integration} ${type?.toLowerCase()}`, integrationExportTitleFirstPart: ({integration}: IntegrationExportParams) => `Seleccione la cuenta ${integration} donde se deben exportar las transacciones. Seleccione una cuenta diferente`, integrationExportTitleLinkPart: 'opción de exportación', @@ -5268,7 +5271,7 @@ const translations = { overLimit: ({formattedLimit}: ViolationsOverLimitParams) => `Importe supera el límite${formattedLimit ? ` de ${formattedLimit}/persona` : ''}`, overLimitAttendee: ({formattedLimit}: ViolationsOverLimitParams) => `Importe supera el límite${formattedLimit ? ` de ${formattedLimit}/persona` : ''}`, perDayLimit: ({formattedLimit}: ViolationsPerDayLimitParams) => `Importe supera el límite diario de la categoría${formattedLimit ? ` de ${formattedLimit}/persona` : ''}`, - receiptNotSmartScanned: 'Recibo no verificado. Por favor, confirma la exactitud', + receiptNotSmartScanned: 'Escaneo de recibo incompleto. Por favor, verifica los detalles manualmente.', receiptRequired: ({formattedLimit, category}: ViolationsReceiptRequiredParams) => { let message = 'Recibo obligatorio'; if (formattedLimit ?? category) { @@ -5703,6 +5706,10 @@ const translations = { emptySearchView: { takeATour: 'Haz un tour', }, + tour: { + takeATwoMinuteTour: 'Haz un tour de 2 minutos', + exploreExpensify: 'Explora todo lo que Expensify tiene para ofrecer', + }, }; export default translations satisfies TranslationDeepObject; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index 0350d59685ce..bf3f749f5bac 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -434,6 +434,7 @@ const WRITE_COMMANDS = { SET_CARD_EXPORT_ACCOUNT: 'SetCardExportAccount', SET_PERSONAL_DETAILS_AND_SHIP_EXPENSIFY_CARDS: 'SetPersonalDetailsAndShipExpensifyCards', SET_INVOICING_TRANSFER_BANK_ACCOUNT: 'SetInvoicingTransferBankAccount', + SELF_TOUR_VIEWED: 'SelfTourViewed', UPDATE_INVOICE_COMPANY_NAME: 'UpdateInvoiceCompanyName', UPDATE_INVOICE_COMPANY_WEBSITE: 'UpdateInvoiceCompanyWebsite', } as const; @@ -861,6 +862,7 @@ type WriteCommandParameters = { [WRITE_COMMANDS.UPDATE_CARD_SETTLEMENT_FREQUENCY]: Parameters.UpdateCardSettlementFrequencyParams; [WRITE_COMMANDS.UPDATE_CARD_SETTLEMENT_ACCOUNT]: Parameters.UpdateCardSettlementAccountParams; [WRITE_COMMANDS.SET_PERSONAL_DETAILS_AND_SHIP_EXPENSIFY_CARDS]: Parameters.SetPersonalDetailsAndShipExpensifyCardsParams; + [WRITE_COMMANDS.SELF_TOUR_VIEWED]: null; // Xero API [WRITE_COMMANDS.UPDATE_XERO_TENANT_ID]: Parameters.UpdateXeroGenericTypeParams; diff --git a/src/libs/ErrorUtils.ts b/src/libs/ErrorUtils.ts index ba2a33a367d4..b880239b8abf 100644 --- a/src/libs/ErrorUtils.ts +++ b/src/libs/ErrorUtils.ts @@ -134,6 +134,15 @@ function getLatestErrorFieldForAnyField Object.assign(acc, error), {}); } +function getLatestError(errors?: Errors): Errors { + if (!errors || Object.keys(errors).length === 0) { + return {}; + } + + const key = Object.keys(errors).sort().reverse().at(0) ?? ''; + return {[key]: getErrorMessageWithTranslationData(errors[key])}; +} + /** * Method used to attach already translated message * @param errors - An object containing current errors in the form @@ -198,6 +207,7 @@ export { getLatestErrorFieldForAnyField, getLatestErrorMessage, getLatestErrorMessageField, + getLatestError, getMicroSecondOnyxErrorWithTranslationKey, getMicroSecondOnyxErrorWithMessage, getMicroSecondOnyxErrorObject, diff --git a/src/libs/MoneyRequestUtils.ts b/src/libs/MoneyRequestUtils.ts index 25c8509efaad..206bb8509af6 100644 --- a/src/libs/MoneyRequestUtils.ts +++ b/src/libs/MoneyRequestUtils.ts @@ -38,21 +38,15 @@ function addLeadingZero(amount: string): string { } /** - * Get amount regex string - */ -function amountRegex(decimals: number, amountMaxLength: number = CONST.IOU.AMOUNT_MAX_LENGTH): string { - return decimals === 0 - ? `^\\d{0,${amountMaxLength}}$` // Don't allow decimal point if decimals === 0 - : `^\\d{0,${amountMaxLength}}(?:(?:\\.|\\,)\\d{0,${decimals}})?$`; // Allow the decimal point and the desired number of digits after the point -} - -/** - * Check if string is a valid amount + * Check if amount is a decimal up to 3 digits */ function validateAmount(amount: string, decimals: number, amountMaxLength: number = CONST.IOU.AMOUNT_MAX_LENGTH): boolean { - const regexString = amountRegex(decimals, amountMaxLength); - const decimalNumberRegex = new RegExp(regexString); - return decimalNumberRegex.test(amount); + const regexString = + decimals === 0 + ? `^\\d{1,${amountMaxLength}}$` // Don't allow decimal point if decimals === 0 + : `^\\d{1,${amountMaxLength}}(\\.\\d{0,${decimals}})?$`; // Allow the decimal point and the desired number of digits after the point + const decimalNumberRegex = new RegExp(regexString, 'i'); + return amount === '' || decimalNumberRegex.test(amount); } /** @@ -104,7 +98,6 @@ export { stripDecimalsFromAmount, stripSpacesFromAmount, replaceCommasWithPeriod, - amountRegex, validateAmount, validatePercentage, }; diff --git a/src/libs/Permissions.ts b/src/libs/Permissions.ts index a02a456c48fe..009724e73e93 100644 --- a/src/libs/Permissions.ts +++ b/src/libs/Permissions.ts @@ -36,10 +36,6 @@ function canUseNetSuiteUSATax(betas: OnyxEntry): boolean { return !!betas?.includes(CONST.BETAS.NETSUITE_USA_TAX) || canUseAllBetas(betas); } -function canUseNewDotCopilot(betas: OnyxEntry): boolean { - return !!betas?.includes(CONST.BETAS.NEW_DOT_COPILOT) || canUseAllBetas(betas); -} - function canUseCategoryAndTagApprovers(betas: OnyxEntry): boolean { return !!betas?.includes(CONST.BETAS.CATEGORY_AND_TAG_APPROVERS) || canUseAllBetas(betas); } @@ -69,7 +65,6 @@ export default { canUseCompanyCardFeeds, canUseDirectFeeds, canUseNetSuiteUSATax, - canUseNewDotCopilot, canUseCombinedTrackSubmit, canUseCategoryAndTagApprovers, canUsePerDiem, diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index fbd2cd654d93..d8133991d62b 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -53,7 +53,7 @@ import type {OriginalMessageChangeLog, PaymentMethodType} from '@src/types/onyx/ import type {Status} from '@src/types/onyx/PersonalDetails'; import type {ConnectionName} from '@src/types/onyx/Policy'; import type {NotificationPreference, Participants, PendingChatMember, Participant as ReportParticipant} from '@src/types/onyx/Report'; -import type {Message, ReportActions} from '@src/types/onyx/ReportAction'; +import type {Message, OldDotReportAction, ReportActions} from '@src/types/onyx/ReportAction'; import type {Comment, TransactionChanges, WaypointCollection} from '@src/types/onyx/Transaction'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import type IconAsset from '@src/types/utils/IconAsset'; @@ -258,6 +258,11 @@ type OptimisticCancelPaymentReportAction = Pick< 'actionName' | 'actorAccountID' | 'message' | 'originalMessage' | 'person' | 'reportActionID' | 'shouldShow' | 'created' | 'pendingAction' >; +type OptimisticChangeFieldAction = Pick< + OldDotReportAction & ReportAction, + 'actionName' | 'actorAccountID' | 'originalMessage' | 'person' | 'reportActionID' | 'created' | 'pendingAction' | 'message' +>; + type OptimisticEditedTaskReportAction = Pick< ReportAction, 'reportActionID' | 'actionName' | 'pendingAction' | 'actorAccountID' | 'automatic' | 'avatar' | 'created' | 'shouldShow' | 'message' | 'person' | 'delegateAccountID' @@ -452,6 +457,7 @@ type OptimisticIOUReport = Pick< | 'parentReportID' | 'lastVisibleActionCreated' | 'fieldList' + | 'parentReportActionID' >; type DisplayNameWithTooltips = Array>; @@ -2570,6 +2576,56 @@ function getReimbursementDeQueuedActionMessage( return Localize.translateLocal('iou.canceledRequest', {submitterDisplayName, amount: formattedAmount}); } +/** + * Builds an optimistic REIMBURSEMENT_DEQUEUED report action with a randomly generated reportActionID. + * + */ +function buildOptimisticChangeFieldAction(reportField: PolicyReportField, previousReportField: PolicyReportField): OptimisticChangeFieldAction { + return { + actionName: CONST.REPORT.ACTIONS.TYPE.CHANGE_FIELD, + actorAccountID: currentUserAccountID, + message: [ + { + type: 'TEXT', + style: 'strong', + text: 'You', + }, + { + type: 'TEXT', + style: 'normal', + text: ` modified field '${reportField.name}'.`, + }, + { + type: 'TEXT', + style: 'normal', + text: ` New value is '${reportField.value}'`, + }, + { + type: 'TEXT', + style: 'normal', + text: ` (previously '${previousReportField.value}').`, + }, + ], + originalMessage: { + fieldName: reportField.name, + newType: reportField.type, + newValue: reportField.value, + oldType: previousReportField.type, + oldValue: previousReportField.value, + }, + person: [ + { + style: 'strong', + text: getCurrentUserDisplayNameOrEmail(), + type: 'TEXT', + }, + ], + reportActionID: NumberUtils.rand64(), + created: DateUtils.getDBTime(), + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + }; +} + /** * Builds an optimistic REIMBURSEMENT_DEQUEUED report action with a randomly generated reportActionID. * @@ -4273,7 +4329,7 @@ function getReportDescription(report: OnyxEntry): string { try { const reportDescription = report?.description; const objectDescription = JSON.parse(reportDescription) as {html: string}; - return objectDescription.html ?? ''; + return objectDescription.html ?? reportDescription ?? ''; } catch (error) { return report?.description ?? ''; } @@ -4448,9 +4504,18 @@ function buildOptimisticTaskCommentReportAction( * @param chatReportID - Report ID of the chat where the IOU is. * @param currency - IOU currency. * @param isSendingMoney - If we pay someone the IOU should be created as settled + * @param parentReportActionID - The parent report action ID of the IOU report */ -function buildOptimisticIOUReport(payeeAccountID: number, payerAccountID: number, total: number, chatReportID: string, currency: string, isSendingMoney = false): OptimisticIOUReport { +function buildOptimisticIOUReport( + payeeAccountID: number, + payerAccountID: number, + total: number, + chatReportID: string, + currency: string, + isSendingMoney = false, + parentReportActionID?: string, +): OptimisticIOUReport { const formattedTotal = CurrencyUtils.convertToDisplayString(total, currency); const personalDetails = getPersonalDetailsForAccountID(payerAccountID); const payerEmail = 'login' in personalDetails ? personalDetails.login : ''; @@ -4480,6 +4545,7 @@ function buildOptimisticIOUReport(payeeAccountID: number, payerAccountID: number parentReportID: chatReportID, lastVisibleActionCreated: DateUtils.getDBTime(), fieldList: policy?.fieldList, + parentReportActionID, }; } @@ -8660,6 +8726,7 @@ export { hasMissingInvoiceBankAccount, reasonForReportToBeInOptionList, getReasonAndReportActionThatRequiresAttention, + buildOptimisticChangeFieldAction, isPolicyRelatedReport, hasReportErrorsOtherThanFailedReceipt, shouldShowViolations, diff --git a/src/libs/TourUtils.ts b/src/libs/TourUtils.ts new file mode 100644 index 000000000000..a88ee47cc563 --- /dev/null +++ b/src/libs/TourUtils.ts @@ -0,0 +1,14 @@ +import type {ValueOf} from 'type-fest'; +import CONST from '@src/CONST'; +import type {OnboardingPurposeType} from '@src/CONST'; + +function getNavatticURL(environment: ValueOf, introSelected?: OnboardingPurposeType) { + const adminTourURL = environment === CONST.ENVIRONMENT.PRODUCTION ? CONST.NAVATTIC.ADMIN_TOUR_PRODUCTION : CONST.NAVATTIC.ADMIN_TOUR_STAGING; + const employeeTourURL = environment === CONST.ENVIRONMENT.PRODUCTION ? CONST.NAVATTIC.EMPLOYEE_TOUR_PRODUCTION : CONST.NAVATTIC.EMPLOYEE_TOUR_STAGING; + return introSelected === CONST.SELECTABLE_ONBOARDING_CHOICES.MANAGE_TEAM ? adminTourURL : employeeTourURL; +} + +export { + // eslint-disable-next-line import/prefer-default-export + getNavatticURL, +}; diff --git a/src/libs/actions/CompanyCards.ts b/src/libs/actions/CompanyCards.ts index a106fbeff510..2955a62f28c7 100644 --- a/src/libs/actions/CompanyCards.ts +++ b/src/libs/actions/CompanyCards.ts @@ -51,13 +51,37 @@ function clearAddNewCardFlow() { }); } -function addNewCompanyCardsFeed(policyID: string, feedType: string, feedDetails: string) { +function addNewCompanyCardsFeed(policyID: string, feedType: CompanyCardFeed, feedDetails: string, lastSelectedFeed?: CompanyCardFeed) { const authToken = NetworkStore.getAuthToken(); if (!authToken) { return; } + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.LAST_SELECTED_FEED}${policyID}`, + value: feedType, + }, + ]; + + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.LAST_SELECTED_FEED}${policyID}`, + value: lastSelectedFeed ?? null, + }, + ]; + + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.LAST_SELECTED_FEED}${policyID}`, + value: feedType, + }, + ]; + const parameters: RequestFeedSetupParams = { policyID, authToken, @@ -65,7 +89,7 @@ function addNewCompanyCardsFeed(policyID: string, feedType: string, feedDetails: feedDetails, }; - API.write(WRITE_COMMANDS.REQUEST_FEED_SETUP, parameters); + API.write(WRITE_COMMANDS.REQUEST_FEED_SETUP, parameters, {optimisticData, failureData, successData}); } function setWorkspaceCompanyCardFeedName(policyID: string, workspaceAccountID: number, bankName: string, userDefinedName: string) { diff --git a/src/libs/actions/Delegate.ts b/src/libs/actions/Delegate.ts index 28f2019bb231..e294a57e6c5f 100644 --- a/src/libs/actions/Delegate.ts +++ b/src/libs/actions/Delegate.ts @@ -47,7 +47,11 @@ function connect(email: string) { key: ONYXKEYS.ACCOUNT, value: { delegatedAccess: { - delegators: delegatedAccess.delegators.map((delegator) => (delegator.email === email ? {...delegator, errorFields: {connect: null}} : delegator)), + errorFields: { + connect: { + [email]: null, + }, + }, }, }, }, @@ -59,7 +63,11 @@ function connect(email: string) { key: ONYXKEYS.ACCOUNT, value: { delegatedAccess: { - delegators: delegatedAccess.delegators.map((delegator) => (delegator.email === email ? {...delegator, errorFields: undefined} : delegator)), + errorFields: { + connect: { + [email]: null, + }, + }, }, }, }, @@ -71,9 +79,11 @@ function connect(email: string) { key: ONYXKEYS.ACCOUNT, value: { delegatedAccess: { - delegators: delegatedAccess.delegators.map((delegator) => - delegator.email === email ? {...delegator, errorFields: {connect: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('delegate.genericError')}} : delegator, - ), + errorFields: { + connect: { + [email]: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('delegate.genericError'), + }, + }, }, }, }, @@ -112,7 +122,7 @@ function disconnect() { key: ONYXKEYS.ACCOUNT, value: { delegatedAccess: { - errorFields: {connect: null}, + errorFields: {disconnect: null}, }, }, }, @@ -136,7 +146,7 @@ function disconnect() { key: ONYXKEYS.ACCOUNT, value: { delegatedAccess: { - errorFields: {connect: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('delegate.genericError')}, + errorFields: {disconnect: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('delegate.genericError')}, }, }, }, @@ -190,7 +200,6 @@ function addDelegate(email: string, role: DelegateRole, validateCode: string) { : { ...delegate, isLoading: true, - errorFields: {addDelegate: null}, pendingFields: {email: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, role: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD}, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, }, @@ -204,7 +213,6 @@ function addDelegate(email: string, role: DelegateRole, validateCode: string) { email, role, isLoading: true, - errorFields: {addDelegate: null}, pendingFields: {email: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, role: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD}, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, }, @@ -218,6 +226,11 @@ function addDelegate(email: string, role: DelegateRole, validateCode: string) { value: { delegatedAccess: { delegates: optimisticDelegateData(), + errorFields: { + addDelegate: { + [email]: null, + }, + }, }, isLoading: true, }, @@ -233,7 +246,6 @@ function addDelegate(email: string, role: DelegateRole, validateCode: string) { : { ...delegate, isLoading: false, - errorFields: {addDelegate: null}, pendingAction: null, pendingFields: {email: null, role: null}, optimisticAccountID: undefined, @@ -247,7 +259,6 @@ function addDelegate(email: string, role: DelegateRole, validateCode: string) { { email, role, - errorFields: {addDelegate: null}, isLoading: false, pendingAction: null, pendingFields: {email: null, role: null}, @@ -263,6 +274,11 @@ function addDelegate(email: string, role: DelegateRole, validateCode: string) { value: { delegatedAccess: { delegates: successDelegateData(), + errorFields: { + addDelegate: { + [email]: null, + }, + }, }, isLoading: false, }, @@ -278,7 +294,6 @@ function addDelegate(email: string, role: DelegateRole, validateCode: string) { : { ...delegate, isLoading: false, - errorFields: {addDelegate: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('contacts.genericFailureMessages.validateSecondaryLogin')}, }, ) ?? [] ); @@ -289,9 +304,6 @@ function addDelegate(email: string, role: DelegateRole, validateCode: string) { { email, role, - errorFields: { - addDelegate: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('contacts.genericFailureMessages.validateSecondaryLogin'), - }, isLoading: false, pendingFields: {email: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, role: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD}, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, @@ -328,11 +340,15 @@ function removeDelegate(email: string) { key: ONYXKEYS.ACCOUNT, value: { delegatedAccess: { + errorFields: { + removeDelegate: { + [email]: null, + }, + }, delegates: delegatedAccess.delegates?.map((delegate) => delegate.email === email ? { ...delegate, - errorFields: {removeDelegate: null}, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, pendingFields: {email: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, role: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE}, } @@ -361,13 +377,15 @@ function removeDelegate(email: string) { key: ONYXKEYS.ACCOUNT, value: { delegatedAccess: { + errorFields: { + removeDelegate: { + [email]: null, + }, + }, delegates: delegatedAccess.delegates?.map((delegate) => delegate.email === email ? { ...delegate, - errorFields: { - removeDelegate: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('delegate.genericError'), - }, pendingAction: null, pendingFields: undefined, } @@ -383,14 +401,18 @@ function removeDelegate(email: string) { API.write(WRITE_COMMANDS.REMOVE_DELEGATE, parameters, {optimisticData, successData, failureData}); } -function clearAddDelegateErrors(email: string, fieldName: string) { +function clearDelegateErrorsByField(email: string, fieldName: string) { if (!delegatedAccess?.delegates) { return; } Onyx.merge(ONYXKEYS.ACCOUNT, { delegatedAccess: { - delegates: delegatedAccess.delegates.map((delegate) => (delegate.email !== email ? delegate : {...delegate, errorFields: {...delegate.errorFields, [fieldName]: null}})), + errorFields: { + [fieldName]: { + [email]: null, + }, + }, }, }); } @@ -422,12 +444,16 @@ function updateDelegateRole(email: string, role: DelegateRole, validateCode: str key: ONYXKEYS.ACCOUNT, value: { delegatedAccess: { + errorFields: { + updateDelegateRole: { + [email]: null, + }, + }, delegates: delegatedAccess.delegates.map((delegate) => delegate.email === email ? { ...delegate, role, - errorFields: {updateDelegateRole: null}, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, pendingFields: {role: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}, isLoading: true, @@ -445,12 +471,16 @@ function updateDelegateRole(email: string, role: DelegateRole, validateCode: str key: ONYXKEYS.ACCOUNT, value: { delegatedAccess: { + errorFields: { + updateDelegateRole: { + [email]: null, + }, + }, delegates: delegatedAccess.delegates.map((delegate) => delegate.email === email ? { ...delegate, role, - errorFields: {updateDelegateRole: null}, pendingAction: null, pendingFields: {role: null}, isLoading: false, @@ -472,9 +502,6 @@ function updateDelegateRole(email: string, role: DelegateRole, validateCode: str delegate.email === email ? { ...delegate, - errorFields: { - updateDelegateRole: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('delegate.genericError'), - }, isLoading: false, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, pendingFields: {role: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}, @@ -502,12 +529,16 @@ function updateDelegateRoleOptimistically(email: string, role: DelegateRole) { key: ONYXKEYS.ACCOUNT, value: { delegatedAccess: { + errorFields: { + updateDelegateRole: { + [email]: null, + }, + }, delegates: delegatedAccess.delegates.map((delegate) => delegate.email === email ? { ...delegate, role, - errorFields: {updateDelegateRole: null}, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, pendingFields: {role: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}, } @@ -568,7 +599,7 @@ export { clearDelegatorErrors, addDelegate, requestValidationCode, - clearAddDelegateErrors, + clearDelegateErrorsByField, removePendingDelegate, restoreDelegateSession, isConnectedAsDelegate, diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts index 6893a66f2050..7a72df9f1d87 100644 --- a/src/libs/actions/IOU.ts +++ b/src/libs/actions/IOU.ts @@ -6571,7 +6571,15 @@ function getReportFromHoldRequestsOnyxData( false, newParentReportActionID, ) - : ReportUtils.buildOptimisticIOUReport(recipient.accountID ?? 1, iouReport?.managerID ?? 1, holdTransactionAmount, chatReport.reportID, getCurrency(firstHoldTransaction), false); + : ReportUtils.buildOptimisticIOUReport( + iouReport?.ownerAccountID ?? -1, + iouReport?.managerID ?? -1, + holdTransactionAmount, + chatReport.reportID, + getCurrency(firstHoldTransaction), + false, + newParentReportActionID, + ); const optimisticExpenseReportPreview = ReportUtils.buildOptimisticReportPreview( chatReport, diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index 3eac21cd1b18..9ea499283d70 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -58,6 +58,8 @@ import DateUtils from '@libs/DateUtils'; import {prepareDraftComment} from '@libs/DraftCommentUtils'; import * as EmojiUtils from '@libs/EmojiUtils'; import * as Environment from '@libs/Environment/Environment'; +import getEnvironment from '@libs/Environment/getEnvironment'; +import type EnvironmentType from '@libs/Environment/getEnvironment/types'; import * as ErrorUtils from '@libs/ErrorUtils'; import fileDownload from '@libs/fileDownload'; import HttpUtils from '@libs/HttpUtils'; @@ -84,6 +86,7 @@ import type {OptimisticAddCommentReportAction} from '@libs/ReportUtils'; import * as ReportUtils from '@libs/ReportUtils'; import {doesReportBelongToWorkspace} from '@libs/ReportUtils'; import shouldSkipDeepLinkNavigation from '@libs/shouldSkipDeepLinkNavigation'; +import {getNavatticURL} from '@libs/TourUtils'; import Visibility from '@libs/Visibility'; import CONFIG from '@src/CONFIG'; import type {OnboardingAccountingType, OnboardingCompanySizeType, OnboardingPurposeType} from '@src/CONST'; @@ -281,6 +284,11 @@ Onyx.connect({ let environmentURL: string; Environment.getEnvironmentURL().then((url: string) => (environmentURL = url)); +let environment: EnvironmentType; +getEnvironment().then((env) => { + environment = env; +}); + registerPaginationConfig({ initialCommand: WRITE_COMMANDS.OPEN_REPORT, previousCommand: READ_COMMANDS.GET_OLDER_ACTIONS, @@ -1935,6 +1943,8 @@ function updateReportField(reportID: string, reportField: PolicyReportField, pre const fieldViolation = ReportUtils.getFieldViolation(reportViolations, reportField); const recentlyUsedValues = allRecentlyUsedReportFields?.[fieldKey] ?? []; + const optimisticChangeFieldAction = ReportUtils.buildOptimisticChangeFieldAction(reportField, previousReportField); + const optimisticData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, @@ -1948,6 +1958,13 @@ function updateReportField(reportID: string, reportField: PolicyReportField, pre }, }, }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, + value: { + [optimisticChangeFieldAction.reportActionID]: optimisticChangeFieldAction, + }, + }, ]; if (fieldViolation) { @@ -1988,6 +2005,15 @@ function updateReportField(reportID: string, reportField: PolicyReportField, pre }, }, }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, + value: { + [optimisticChangeFieldAction.reportActionID]: { + errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('report.genericUpdateReportFieldFailureMessage'), + }, + }, + }, ]; if (reportField.type === 'dropdown') { @@ -2013,11 +2039,21 @@ function updateReportField(reportID: string, reportField: PolicyReportField, pre }, }, }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, + value: { + [optimisticChangeFieldAction.reportActionID]: { + pendingAction: null, + }, + }, + }, ]; const parameters = { reportID, reportFields: JSON.stringify({[fieldKey]: reportField}), + reportFieldsActionIDs: JSON.stringify({[fieldKey]: optimisticChangeFieldAction.reportActionID}), }; API.write(WRITE_COMMANDS.SET_REPORT_FIELD, parameters, {optimisticData, failureData, successData}); @@ -3436,7 +3472,6 @@ function completeOnboarding( reportComment: videoComment.commentText, }; } - const tasksData = data.tasks .filter((task) => { if (task.type === 'addAccountingIntegration' && !userReportedIntegration) { @@ -3452,6 +3487,7 @@ function completeOnboarding( workspaceCategoriesLink: `${environmentURL}/${ROUTES.WORKSPACE_CATEGORIES.getRoute(onboardingPolicyID ?? '-1')}`, workspaceMembersLink: `${environmentURL}/${ROUTES.WORKSPACE_MEMBERS.getRoute(onboardingPolicyID ?? '-1')}`, workspaceMoreFeaturesLink: `${environmentURL}/${ROUTES.WORKSPACE_MORE_FEATURES.getRoute(onboardingPolicyID ?? '-1')}`, + navatticURL: getNavatticURL(environment, engagementChoice), integrationName, workspaceAccountingLink: `${environmentURL}/${ROUTES.POLICY_ACCOUNTING.getRoute(onboardingPolicyID ?? '-1')}`, }) @@ -3505,7 +3541,7 @@ function completeOnboarding( parentReportActionID: taskReportAction.reportAction.reportActionID, assigneeChatReportID: '', createdTaskReportActionID: taskCreatedAction.reportActionID, - completedTaskReportActionID: completedTaskReportAction?.reportActionID ?? '-1', + completedTaskReportActionID: completedTaskReportAction?.reportActionID ?? undefined, title: currentTask.reportName ?? '', description: taskDescription ?? '', })); diff --git a/src/libs/actions/Welcome/index.ts b/src/libs/actions/Welcome/index.ts index d504c5550331..19a570ab610f 100644 --- a/src/libs/actions/Welcome/index.ts +++ b/src/libs/actions/Welcome/index.ts @@ -2,7 +2,7 @@ import {NativeModules} from 'react-native'; import type {OnyxUpdate} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; import * as API from '@libs/API'; -import {SIDE_EFFECT_REQUEST_COMMANDS} from '@libs/API/types'; +import {SIDE_EFFECT_REQUEST_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; import Log from '@libs/Log'; import type {OnboardingCompanySizeType, OnboardingPurposeType} from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -183,6 +183,20 @@ function resetAllChecks() { OnboardingFlow.clearInitialPath(); } +function setSelfTourViewed() { + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.NVP_ONBOARDING, + value: { + selfTourViewed: true, + }, + }, + ]; + + API.write(WRITE_COMMANDS.SELF_TOUR_VIEWED, null, {optimisticData}); +} + export { onServerDataReady, isOnboardingFlowCompleted, @@ -195,4 +209,5 @@ export { completeHybridAppOnboarding, setOnboardingErrorMessage, setOnboardingCompanySize, + setSelfTourViewed, }; diff --git a/src/libs/onboardingSelectors.ts b/src/libs/onboardingSelectors.ts index efa67d2aed48..c1e7d0ed0778 100644 --- a/src/libs/onboardingSelectors.ts +++ b/src/libs/onboardingSelectors.ts @@ -35,4 +35,19 @@ function hasCompletedHybridAppOnboardingFlowSelector(tryNewDotData: OnyxValue): boolean | undefined { + if (Array.isArray(onboarding)) { + return false; + } + + return onboarding?.selfTourViewed; +} + +export {hasCompletedGuidedSetupFlowSelector, hasCompletedHybridAppOnboardingFlowSelector, hasSeenTourSelector}; diff --git a/src/pages/ReimbursementAccount/ReimbursementAccountPage.tsx b/src/pages/ReimbursementAccount/ReimbursementAccountPage.tsx index c2b3bd60cb99..29173817f5ac 100644 --- a/src/pages/ReimbursementAccount/ReimbursementAccountPage.tsx +++ b/src/pages/ReimbursementAccount/ReimbursementAccountPage.tsx @@ -170,16 +170,6 @@ function ReimbursementAccountPage({route, policy}: ReimbursementAccountPageProps const currentStep = !isPreviousPolicy ? CONST.BANK_ACCOUNT.STEP.BANK_ACCOUNT : achData?.currentStep || CONST.BANK_ACCOUNT.STEP.BANK_ACCOUNT; const [nonUSDBankAccountStep, setNonUSDBankAccountStep] = useState(CONST.NON_USD_BANK_ACCOUNT.STEP.COUNTRY); - /** - When this page is first opened, `reimbursementAccount` prop might not yet be fully loaded from Onyx. - Calculating `shouldShowContinueSetupButton` immediately on initial render doesn't make sense as - it relies on incomplete data. Thus, we should wait to calculate it until we have received - the full `reimbursementAccount` data from the server. This logic is handled within the useEffect hook, - which acts similarly to `componentDidUpdate` when the `reimbursementAccount` dependency changes. - */ - const [hasACHDataBeenLoaded, setHasACHDataBeenLoaded] = useState(reimbursementAccount !== CONST.REIMBURSEMENT_ACCOUNT.DEFAULT_DATA && isPreviousPolicy); - const [shouldShowContinueSetupButton, setShouldShowContinueSetupButton] = useState(getShouldShowContinueSetupButtonInitialValue()); - function getBankAccountFields(fieldNames: InputID[]): Partial { return { ...lodashPick(reimbursementAccount?.achData, ...fieldNames), @@ -205,6 +195,16 @@ function ReimbursementAccountPage({route, policy}: ReimbursementAccountPageProps return achData?.state === BankAccount.STATE.PENDING || [CONST.BANK_ACCOUNT.STEP.BANK_ACCOUNT, ''].includes(getStepToOpenFromRouteParams(route)); } + /** + When this page is first opened, `reimbursementAccount` prop might not yet be fully loaded from Onyx. + Calculating `shouldShowContinueSetupButton` immediately on initial render doesn't make sense as + it relies on incomplete data. Thus, we should wait to calculate it until we have received + the full `reimbursementAccount` data from the server. This logic is handled within the useEffect hook, + which acts similarly to `componentDidUpdate` when the `reimbursementAccount` dependency changes. + */ + const [hasACHDataBeenLoaded, setHasACHDataBeenLoaded] = useState(reimbursementAccount !== CONST.REIMBURSEMENT_ACCOUNT.DEFAULT_DATA && isPreviousPolicy); + const [shouldShowContinueSetupButton, setShouldShowContinueSetupButton] = useState(getShouldShowContinueSetupButtonInitialValue()); + const handleNextNonUSDBankAccountStep = () => { switch (nonUSDBankAccountStep) { case CONST.NON_USD_BANK_ACCOUNT.STEP.COUNTRY: diff --git a/src/pages/Search/EmptySearchView.tsx b/src/pages/Search/EmptySearchView.tsx index 8e61978c169e..19d00a06771e 100644 --- a/src/pages/Search/EmptySearchView.tsx +++ b/src/pages/Search/EmptySearchView.tsx @@ -10,12 +10,14 @@ import MenuItem from '@components/MenuItem'; import SearchRowSkeleton from '@components/Skeletons/SearchRowSkeleton'; import Text from '@components/Text'; import TextLink from '@components/TextLink'; +import useEnvironment from '@hooks/useEnvironment'; import useLocalize from '@hooks/useLocalize'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import interceptAnonymousUser from '@libs/interceptAnonymousUser'; import * as ReportUtils from '@libs/ReportUtils'; +import {getNavatticURL} from '@libs/TourUtils'; import * as TripsResevationUtils from '@libs/TripReservationUtils'; import variables from '@styles/variables'; import * as IOU from '@userActions/IOU'; @@ -93,7 +95,8 @@ function EmptySearchView({type}: EmptySearchViewProps) { }, [styles, translate, ctaErrorMessage]); const [onboardingPurpose] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {selector: (introSelected) => introSelected?.choice}); - const navatticLink = onboardingPurpose === CONST.SELECTABLE_ONBOARDING_CHOICES.MANAGE_TEAM ? CONST.NAVATTIC.ADMIN_TOUR : CONST.NAVATTIC.EMPLOYEE_TOUR; + const {environment} = useEnvironment(); + const navatticURL = getNavatticURL(environment, onboardingPurpose); const content = useMemo(() => { switch (type) { @@ -120,7 +123,7 @@ function EmptySearchView({type}: EmptySearchViewProps) { title: translate('search.searchResults.emptyExpenseResults.title'), subtitle: translate('search.searchResults.emptyExpenseResults.subtitle'), buttons: [ - {buttonText: translate('emptySearchView.takeATour'), buttonAction: () => Link.openExternalLink(navatticLink)}, + {buttonText: translate('emptySearchView.takeATour'), buttonAction: () => Link.openExternalLink(navatticURL)}, { buttonText: translate('iou.createExpense'), buttonAction: () => interceptAnonymousUser(() => IOU.startMoneyRequest(CONST.IOU.TYPE.CREATE, ReportUtils.generateReportID())), @@ -140,7 +143,7 @@ function EmptySearchView({type}: EmptySearchViewProps) { headerContentStyles: styles.emptyStateFolderWebStyles, }; } - }, [type, StyleUtils, translate, theme, styles, subtitleComponent, ctaErrorMessage, navatticLink]); + }, [type, StyleUtils, translate, theme, styles, subtitleComponent, ctaErrorMessage, navatticURL]); return ( { SearchActions.clearAdvancedFilters(); + Navigation.dismissModal(); Navigation.navigate( ROUTES.SEARCH_CENTRAL_PANE.getRoute({ query: q, diff --git a/src/pages/home/ReportScreen.tsx b/src/pages/home/ReportScreen.tsx index 235cab0753f2..0ca9dcdc2de3 100644 --- a/src/pages/home/ReportScreen.tsx +++ b/src/pages/home/ReportScreen.tsx @@ -10,6 +10,7 @@ import {useOnyx} from 'react-native-onyx'; import Banner from '@components/Banner'; import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; import DragAndDropProvider from '@components/DragAndDrop/Provider'; +import LoadingBar from '@components/LoadingBar'; import MoneyReportHeader from '@components/MoneyReportHeader'; import MoneyRequestHeader from '@components/MoneyRequestHeader'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; @@ -129,6 +130,7 @@ function ReportScreen({route, currentReportID = '', navigation}: ReportScreenPro const [isLoadingApp] = useOnyx(ONYXKEYS.IS_LOADING_APP); const [workspaceTooltip] = useOnyx(ONYXKEYS.NVP_WORKSPACE_TOOLTIP); const wasLoadingApp = usePrevious(isLoadingApp); + const [isLoadingReportData] = useOnyx(ONYXKEYS.IS_LOADING_REPORT_DATA, {initialValue: true}); const finishedLoadingApp = wasLoadingApp && !isLoadingApp; const isDeletedParentAction = ReportActionsUtils.isDeletedParentAction(parentReportAction); const prevIsDeletedParentAction = usePrevious(isDeletedParentAction); @@ -720,6 +722,9 @@ function ReportScreen({route, currentReportID = '', navigation}: ReportScreenPro const lastRoute = usePrevious(route); const lastReportActionIDFromRoute = usePrevious(reportActionIDFromRoute); + const onComposerFocus = useCallback(() => setIsComposerFocus(true), []); + const onComposerBlur = useCallback(() => setIsComposerFocus(false), []); + // Define here because reportActions are recalculated before mount, allowing data to display faster than useEffect can trigger. // If we have cached reportActions, they will be shown immediately. // We aim to display a loader first, then fetch relevant reportActions, and finally show them. @@ -753,6 +758,7 @@ function ReportScreen({route, currentReportID = '', navigation}: ReportScreenPro needsOffscreenAlphaCompositing > {headerView} + {shouldUseNarrowLayout && !!isLoadingReportData && } {!!report && ReportUtils.isTaskReport(report) && shouldUseNarrowLayout && ReportUtils.isOpenTaskReport(report, parentReportAction) && ( @@ -822,8 +828,8 @@ function ReportScreen({route, currentReportID = '', navigation}: ReportScreenPro {isCurrentReportLoadedFromOnyx ? ( setIsComposerFocus(true)} - onComposerBlur={() => setIsComposerFocus(false)} + onComposerFocus={onComposerFocus} + onComposerBlur={onComposerBlur} report={report} reportMetadata={reportMetadata} policy={policy} diff --git a/src/pages/home/report/ContextMenu/ContextMenuActions.tsx b/src/pages/home/report/ContextMenu/ContextMenuActions.tsx index 6f3a1e8e565a..a3dc9cbba25f 100644 --- a/src/pages/home/report/ContextMenu/ContextMenuActions.tsx +++ b/src/pages/home/report/ContextMenu/ContextMenuActions.tsx @@ -309,7 +309,8 @@ const ContextMenuActions: ContextMenuAction[] = [ const isThreadFirstChat = ReportUtils.isThreadFirstChat(reportAction, reportID); const isWhisperAction = ReportActionsUtils.isWhisperAction(reportAction) || ReportActionsUtils.isActionableTrackExpense(reportAction); const isExpenseReportAction = ReportActionsUtils.isMoneyRequestAction(reportAction) || ReportActionsUtils.isReportPreviewAction(reportAction); - return !subscribed && !isWhisperAction && !isExpenseReportAction && !isThreadFirstChat && (shouldDisplayThreadReplies || (!isDeletedAction && !isArchivedRoom)); + const isTaskAction = ReportActionsUtils.isCreatedTaskReportAction(reportAction); + return !subscribed && !isWhisperAction && !isTaskAction && !isExpenseReportAction && !isThreadFirstChat && (shouldDisplayThreadReplies || (!isDeletedAction && !isArchivedRoom)); }, onPress: (closePopover, {reportAction, reportID}) => { const childReportNotificationPreference = ReportUtils.getChildReportNotificationPreference(reportAction); diff --git a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx index 9ae2eaa2eaad..1cb70fe6c926 100644 --- a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx +++ b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx @@ -1,6 +1,6 @@ import {useIsFocused, useNavigation} from '@react-navigation/native'; import lodashDebounce from 'lodash/debounce'; -import type {ForwardedRef, MutableRefObject, RefAttributes, RefObject} from 'react'; +import type {ForwardedRef, MutableRefObject, RefObject} from 'react'; import React, {forwardRef, memo, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react'; import type { LayoutChangeEvent, @@ -14,7 +14,7 @@ import type { import {DeviceEventEmitter, findNodeHandle, InteractionManager, NativeModules, View} from 'react-native'; import {useFocusedInputHandler} from 'react-native-keyboard-controller'; import type {OnyxEntry} from 'react-native-onyx'; -import {useOnyx, withOnyx} from 'react-native-onyx'; +import {useOnyx} from 'react-native-onyx'; import {useAnimatedRef, useSharedValue} from 'react-native-reanimated'; import type {Emoji} from '@assets/emojis/types'; import type {FileObject} from '@components/AttachmentModal'; @@ -65,113 +65,95 @@ type SyncSelection = { type NewlyAddedChars = {startIndex: number; endIndex: number; diff: string}; -type ComposerWithSuggestionsOnyxProps = { - /** The parent report actions for the report */ - parentReportActions: OnyxEntry; +type ComposerWithSuggestionsProps = Partial & { + /** Report ID */ + reportID: string; - /** The modal state */ - modal: OnyxEntry; + /** Callback to focus composer */ + onFocus: () => void; - /** The preferred skin tone of the user */ - preferredSkinTone: number; + /** Callback to blur composer */ + onBlur: (event: NativeSyntheticEvent) => void; - /** Whether the input is focused */ - editFocused: OnyxEntry; -}; - -type ComposerWithSuggestionsProps = ComposerWithSuggestionsOnyxProps & - Partial & { - /** Report ID */ - reportID: string; - - /** Callback to focus composer */ - onFocus: () => void; - - /** Callback to blur composer */ - onBlur: (event: NativeSyntheticEvent) => void; - - /** Callback when layout of composer changes */ - onLayout?: (event: LayoutChangeEvent) => void; + /** Callback when layout of composer changes */ + onLayout?: (event: LayoutChangeEvent) => void; - /** Callback to update the value of the composer */ - onValueChange: (value: string) => void; + /** Callback to update the value of the composer */ + onValueChange: (value: string) => void; - /** Callback when the composer got cleared on the UI thread */ - onCleared?: (text: string) => void; + /** Callback when the composer got cleared on the UI thread */ + onCleared?: (text: string) => void; - /** Whether the composer is full size */ - isComposerFullSize: boolean; + /** Whether the composer is full size */ + isComposerFullSize: boolean; - /** Whether the menu is visible */ - isMenuVisible: boolean; + /** Whether the menu is visible */ + isMenuVisible: boolean; - /** The placeholder for the input */ - inputPlaceholder: string; + /** The placeholder for the input */ + inputPlaceholder: string; - /** Function to display a file in a modal */ - displayFileInModal: (file: FileObject) => void; + /** Function to display a file in a modal */ + displayFileInModal: (file: FileObject) => void; - /** Whether the user is blocked from concierge */ - isBlockedFromConcierge: boolean; + /** Whether the user is blocked from concierge */ + isBlockedFromConcierge: boolean; - /** Whether the input is disabled */ - disabled: boolean; + /** Whether the input is disabled */ + disabled: boolean; - /** Whether the full composer is available */ - isFullComposerAvailable: boolean; + /** Whether the full composer is available */ + isFullComposerAvailable: boolean; - /** Function to set whether the full composer is available */ - setIsFullComposerAvailable: (isFullComposerAvailable: boolean) => void; + /** Function to set whether the full composer is available */ + setIsFullComposerAvailable: (isFullComposerAvailable: boolean) => void; - /** Function to set whether the comment is empty */ - setIsCommentEmpty: (isCommentEmpty: boolean) => void; + /** Function to set whether the comment is empty */ + setIsCommentEmpty: (isCommentEmpty: boolean) => void; - /** Function to handle sending a message */ - handleSendMessage: () => void; + /** Function to handle sending a message */ + handleSendMessage: () => void; - /** Whether the compose input should show */ - shouldShowComposeInput: OnyxEntry; + /** Whether the compose input should show */ + shouldShowComposeInput: OnyxEntry; - /** Function to measure the parent container */ - measureParentContainer: (callback: MeasureInWindowOnSuccessCallback) => void; + /** Function to measure the parent container */ + measureParentContainer: (callback: MeasureInWindowOnSuccessCallback) => void; - /** Whether the scroll is likely to trigger a layout */ - isScrollLikelyLayoutTriggered: RefObject; + /** Whether the scroll is likely to trigger a layout */ + isScrollLikelyLayoutTriggered: RefObject; - /** Function to raise the scroll is likely layout triggered */ - raiseIsScrollLikelyLayoutTriggered: () => void; + /** Function to raise the scroll is likely layout triggered */ + raiseIsScrollLikelyLayoutTriggered: () => void; - /** The ref to the suggestions */ - suggestionsRef: React.RefObject; + /** The ref to the suggestions */ + suggestionsRef: React.RefObject; - /** The ref to the next modal will open */ - isNextModalWillOpenRef: MutableRefObject; + /** The ref to the next modal will open */ + isNextModalWillOpenRef: MutableRefObject; - /** Whether the edit is focused */ - editFocused: boolean; + /** Wheater chat is empty */ + isEmptyChat?: boolean; - /** Wheater chat is empty */ - isEmptyChat?: boolean; + /** The last report action */ + lastReportAction?: OnyxEntry; - /** The last report action */ - lastReportAction?: OnyxEntry; + /** Whether to include chronos */ + includeChronos?: boolean; - /** Whether to include chronos */ - includeChronos?: boolean; + /** The parent report action ID */ + parentReportActionID?: string; - /** The parent report action ID */ - parentReportActionID?: string; + /** The parent report ID */ + // eslint-disable-next-line react/no-unused-prop-types -- its used in the withOnyx HOC + parentReportID: string | undefined; - /** The parent report ID */ - // eslint-disable-next-line react/no-unused-prop-types -- its used in the withOnyx HOC - parentReportID: string | undefined; + /** Whether report is from group policy */ + isGroupPolicyReport: boolean; - /** Whether report is from group policy */ - isGroupPolicyReport: boolean; - - /** policy ID of the report */ - policyID: string; - }; + /** policy ID of the report */ + policyID: string; +}; type SwitchToCurrentReportProps = { preexistingReportID: string; @@ -223,13 +205,9 @@ const shouldFocusInputOnScreenFocus = canFocusInputOnScreenFocus(); */ function ComposerWithSuggestions( { - // Onyx - modal, - preferredSkinTone = CONST.EMOJI_DEFAULT_SKIN_TONE, - parentReportActions, - // Props: Report reportID, + parentReportID, includeChronos, isEmptyChat, lastReportAction, @@ -263,7 +241,6 @@ function ComposerWithSuggestions( // Refs suggestionsRef, isNextModalWillOpenRef, - editFocused, // For testing children, @@ -290,6 +267,13 @@ function ComposerWithSuggestions( }); const commentRef = useRef(value); + const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); + const [modal] = useOnyx(ONYXKEYS.MODAL); + const [preferredSkinTone = CONST.EMOJI_DEFAULT_SKIN_TONE] = useOnyx(ONYXKEYS.PREFERRED_EMOJI_SKIN_TONE, {selector: EmojiUtils.getPreferredSkinToneIndex}); + const [editFocused] = useOnyx(ONYXKEYS.INPUT_FOCUSED); + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const [parentReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentReportID || '-1'}`, {canEvict: false, initWithStoredValues: false}); + const lastTextRef = useRef(value); useEffect(() => { lastTextRef.current = value; @@ -298,8 +282,7 @@ function ComposerWithSuggestions( const {shouldUseNarrowLayout} = useResponsiveLayout(); const maxComposerLines = shouldUseNarrowLayout ? CONST.COMPOSER.MAX_LINES_SMALL_SCREEN : CONST.COMPOSER.MAX_LINES; - const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); - const parentReportAction = parentReportActions?.[parentReportActionID ?? '-1']; + const parentReportAction = useMemo(() => parentReportActions?.[parentReportActionID ?? '-1'], [parentReportActionID, parentReportActions]); const shouldAutoFocus = !modal?.isVisible && Modal.areAllModalsHidden() && @@ -653,6 +636,7 @@ function ComposerWithSuggestions( const prevIsModalVisible = usePrevious(modal?.isVisible); const prevIsFocused = usePrevious(isFocused); + useEffect(() => { if (modal?.isVisible && !prevIsModalVisible) { // eslint-disable-next-line react-compiler/react-compiler, no-param-reassign @@ -683,6 +667,7 @@ function ComposerWithSuggestions( updateMultilineInputRange(textInputRef.current, !!shouldAutoFocus); // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps }, []); + useImperativeHandle( ref, () => ({ @@ -836,24 +821,6 @@ function ComposerWithSuggestions( ComposerWithSuggestions.displayName = 'ComposerWithSuggestions'; -const ComposerWithSuggestionsWithRef = forwardRef(ComposerWithSuggestions); - -export default withOnyx, ComposerWithSuggestionsOnyxProps>({ - modal: { - key: ONYXKEYS.MODAL, - }, - preferredSkinTone: { - key: ONYXKEYS.PREFERRED_EMOJI_SKIN_TONE, - selector: EmojiUtils.getPreferredSkinToneIndex, - }, - editFocused: { - key: ONYXKEYS.INPUT_FOCUSED, - }, - parentReportActions: { - key: ({parentReportID}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentReportID}`, - canEvict: false, - initWithStoredValues: false, - }, -})(memo(ComposerWithSuggestionsWithRef)); +export default memo(forwardRef(ComposerWithSuggestions)); export type {ComposerWithSuggestionsProps, ComposerRef}; diff --git a/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx b/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx index 14908014ca03..23b059f2fda2 100644 --- a/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx +++ b/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx @@ -393,6 +393,16 @@ function ReportActionCompose({ ], ); + const onValueChange = useCallback( + (value: string) => { + if (value.length === 0 && isComposerFullSize) { + Report.setIsComposerFullSize(reportID, false); + } + validateCommentMaxLength(value, {reportID}); + }, + [isComposerFullSize, reportID, validateCommentMaxLength], + ); + return ( @@ -490,12 +500,7 @@ function ReportActionCompose({ onFocus={onFocus} onBlur={onBlur} measureParentContainer={measureContainer} - onValueChange={(value) => { - if (value.length === 0 && isComposerFullSize) { - Report.setIsComposerFullSize(reportID, false); - } - validateCommentMaxLength(value, {reportID}); - }} + onValueChange={onValueChange} /> { diff --git a/src/pages/home/sidebar/SidebarScreen/BaseSidebarScreen.tsx b/src/pages/home/sidebar/SidebarScreen/BaseSidebarScreen.tsx index e77f2000b85f..77c21d4ab2e1 100644 --- a/src/pages/home/sidebar/SidebarScreen/BaseSidebarScreen.tsx +++ b/src/pages/home/sidebar/SidebarScreen/BaseSidebarScreen.tsx @@ -1,6 +1,7 @@ import React, {useEffect} from 'react'; import {View} from 'react-native'; import {useOnyx} from 'react-native-onyx'; +import LoadingBar from '@components/LoadingBar'; import ScreenWrapper from '@components/ScreenWrapper'; import useActiveWorkspaceFromNavigationState from '@hooks/useActiveWorkspaceFromNavigationState'; import useLocalize from '@hooks/useLocalize'; @@ -31,6 +32,8 @@ function BaseSidebarScreen() { const {shouldUseNarrowLayout} = useResponsiveLayout(); const [activeWorkspace] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${activeWorkspaceID ?? -1}`); + const [isLoadingReportData] = useOnyx(ONYXKEYS.IS_LOADING_REPORT_DATA, {initialValue: true}); + useEffect(() => { Performance.markStart(CONST.TIMING.SIDEBAR_LOADED); Timing.start(CONST.TIMING.SIDEBAR_LOADED); @@ -62,6 +65,7 @@ function BaseSidebarScreen() { activeWorkspaceID={activeWorkspaceID} shouldDisplaySearch={shouldDisplaySearch} /> + { type PolicySelector = Pick; -type FloatingActionButtonAndPopoverOnyxProps = { - /** The list of policies the user has access to. */ - allPolicies: OnyxCollection; - - /** Whether app is in loading state */ - isLoading: OnyxEntry; - - /** Information on the last taken action to display as Quick Action */ - quickAction: OnyxEntry; - - /** The report data of the quick action */ - quickActionReport: OnyxEntry; - - /** The policy data of the quick action */ - quickActionPolicy: OnyxEntry; - - /** The current session */ - session: OnyxEntry; - - /** Personal details of all the users */ - personalDetails: OnyxEntry; - - /** Has user seen track expense training interstitial */ - hasSeenTrackTraining: OnyxEntry; -}; - -type FloatingActionButtonAndPopoverProps = FloatingActionButtonAndPopoverOnyxProps & { +type FloatingActionButtonAndPopoverProps = { /* Callback function when the menu is shown */ onShowCreateMenu?: () => void; @@ -161,24 +143,20 @@ const getQuickActionTitle = (action: QuickActionName): TranslationPaths => { * Responsible for rendering the {@link PopoverMenu}, and the accompanying * FAB that can open or close the menu. */ -function FloatingActionButtonAndPopover( - { - onHideCreateMenu, - onShowCreateMenu, - isLoading = false, - allPolicies, - quickAction, - quickActionReport, - quickActionPolicy, - session, - personalDetails, - hasSeenTrackTraining, - }: FloatingActionButtonAndPopoverProps, - ref: ForwardedRef, -) { +function FloatingActionButtonAndPopover({onHideCreateMenu, onShowCreateMenu}: FloatingActionButtonAndPopoverProps, ref: ForwardedRef) { const styles = useThemeStyles(); + const theme = useTheme(); const {translate} = useLocalize(); + const [isLoading = false] = useOnyx(ONYXKEYS.IS_LOADING_APP); + const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST); + const [session] = useOnyx(ONYXKEYS.SESSION); + const [quickAction] = useOnyx(ONYXKEYS.NVP_QUICK_ACTION_GLOBAL_CREATE); + const [quickActionReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${quickAction?.chatReportID}`); const [reportNameValuePairs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${quickActionReport?.reportID ?? -1}`); + const [quickActionPolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${quickActionReport?.policyID}`); + const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: (c) => mapOnyxCollectionItems(c, policySelector)}); + const [hasSeenTrackTraining] = useOnyx(ONYXKEYS.NVP_HAS_SEEN_TRACK_TRAINING); + const [isCreateMenuActive, setIsCreateMenuActive] = useState(false); const fabRef = useRef(null); const {windowHeight} = useWindowDimensions(); @@ -189,6 +167,12 @@ function FloatingActionButtonAndPopover( const {canUseSpotnanaTravel, canUseCombinedTrackSubmit} = usePermissions(); const canSendInvoice = useMemo(() => PolicyUtils.canSendInvoice(allPolicies as OnyxCollection, session?.email), [allPolicies, session?.email]); + const {environment} = useEnvironment(); + const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); + const navatticURL = getNavatticURL(environment, introSelected?.choice); + const [hasSeenTour = false] = useOnyx(ONYXKEYS.NVP_ONBOARDING, { + selector: hasSeenTourSelector, + }); const quickActionAvatars = useMemo(() => { if (quickActionReport) { @@ -454,14 +438,29 @@ function FloatingActionButtonAndPopover( }, ] : []), + ...(!hasSeenTour + ? [ + { + icon: Expensicons.Binoculars, + iconStyles: styles.popoverIconCircle, + iconFill: theme.icon, + text: translate('tour.takeATwoMinuteTour'), + description: translate('tour.exploreExpensify'), + onSelected: () => { + Welcome.setSelfTourViewed(); + Link.openExternalLink(navatticURL); + }, + }, + ] + : []), ...(!isLoading && !Policy.hasActiveChatEnabledPolicies(allPolicies) ? [ { displayInDefaultIconColor: true, contentFit: 'contain' as ImageContentFit, icon: Expensicons.NewWorkspace, - iconWidth: 46, - iconHeight: 40, + iconWidth: variables.w46, + iconHeight: variables.h40, text: translate('workspace.new.newWorkspace'), description: translate('workspace.new.getTheExpensifyCardAndMore'), onSelected: () => interceptAnonymousUser(() => App.createWorkspaceWithPolicyDraftAndNavigateToIt()), @@ -510,32 +509,6 @@ function FloatingActionButtonAndPopover( FloatingActionButtonAndPopover.displayName = 'FloatingActionButtonAndPopover'; -export default withOnyx, FloatingActionButtonAndPopoverOnyxProps>({ - allPolicies: { - key: ONYXKEYS.COLLECTION.POLICY, - selector: policySelector, - }, - isLoading: { - key: ONYXKEYS.IS_LOADING_APP, - }, - quickAction: { - key: ONYXKEYS.NVP_QUICK_ACTION_GLOBAL_CREATE, - }, - quickActionReport: { - key: ({quickAction}) => `${ONYXKEYS.COLLECTION.REPORT}${quickAction?.chatReportID}`, - }, - quickActionPolicy: { - key: ({quickActionReport}) => `${ONYXKEYS.COLLECTION.POLICY}${quickActionReport?.policyID}`, - }, - personalDetails: { - key: ONYXKEYS.PERSONAL_DETAILS_LIST, - }, - session: { - key: ONYXKEYS.SESSION, - }, - hasSeenTrackTraining: { - key: ONYXKEYS.NVP_HAS_SEEN_TRACK_TRAINING, - }, -})(forwardRef(FloatingActionButtonAndPopover)); +export default forwardRef(FloatingActionButtonAndPopover); export type {PolicySelector}; diff --git a/src/pages/iou/request/step/IOURequestStepParticipants.tsx b/src/pages/iou/request/step/IOURequestStepParticipants.tsx index 34e77b19d078..65e041180408 100644 --- a/src/pages/iou/request/step/IOURequestStepParticipants.tsx +++ b/src/pages/iou/request/step/IOURequestStepParticipants.tsx @@ -1,7 +1,6 @@ import {useIsFocused} from '@react-navigation/core'; import React, {useCallback, useEffect, useMemo, useRef} from 'react'; -import type {OnyxEntry} from 'react-native-onyx'; -import {withOnyx} from 'react-native-onyx'; +import {useOnyx} from 'react-native-onyx'; import FormHelpMessage from '@components/FormHelpMessage'; import useLocalize from '@hooks/useLocalize'; import usePermissions from '@hooks/usePermissions'; @@ -26,13 +25,7 @@ import withFullTransactionOrNotFound from './withFullTransactionOrNotFound'; import type {WithWritableReportOrNotFoundProps} from './withWritableReportOrNotFound'; import withWritableReportOrNotFound from './withWritableReportOrNotFound'; -type IOURequestStepParticipantsOnyxProps = { - /** Whether the confirmation step should be skipped */ - skipConfirmation: OnyxEntry; -}; - -type IOURequestStepParticipantsProps = IOURequestStepParticipantsOnyxProps & - WithWritableReportOrNotFoundProps & +type IOURequestStepParticipantsProps = WithWritableReportOrNotFoundProps & WithFullTransactionOrNotFoundProps; function IOURequestStepParticipants({ @@ -40,13 +33,13 @@ function IOURequestStepParticipants({ params: {iouType, reportID, transactionID, action}, }, transaction, - skipConfirmation, }: IOURequestStepParticipantsProps) { const participants = transaction?.participants; const {translate} = useLocalize(); const styles = useThemeStyles(); const isFocused = useIsFocused(); const {canUseP2PDistanceRequests} = usePermissions(iouType); + const [skipConfirmation] = useOnyx(`${ONYXKEYS.COLLECTION.SKIP_CONFIRMATION}${transactionID ?? -1}`); // We need to set selectedReportID if user has navigated back from confirmation page and navigates to confirmation page with already selected participant const selectedReportID = useRef(participants?.length === 1 ? participants.at(0)?.reportID ?? reportID : reportID); @@ -161,6 +154,8 @@ function IOURequestStepParticipants({ return; } + const rateID = DistanceRequestUtils.getCustomUnitRateID(selfDMReportID, !canUseP2PDistanceRequests); + IOU.setCustomUnitRateID(transactionID, rateID); IOU.setMoneyRequestParticipantsFromReport(transactionID, ReportUtils.getReport(selfDMReportID)); const iouConfirmationPageRoute = ROUTES.MONEY_REQUEST_STEP_CONFIRMATION.getRoute(action, CONST.IOU.TYPE.TRACK, transactionID, selfDMReportID); Navigation.navigate(iouConfirmationPageRoute); @@ -207,13 +202,4 @@ function IOURequestStepParticipants({ IOURequestStepParticipants.displayName = 'IOURequestStepParticipants'; -const IOURequestStepParticipantsWithOnyx = withOnyx({ - skipConfirmation: { - key: ({route}) => { - const transactionID = route.params.transactionID ?? -1; - return `${ONYXKEYS.COLLECTION.SKIP_CONFIRMATION}${transactionID}`; - }, - }, -})(IOURequestStepParticipants); - -export default withWritableReportOrNotFound(withFullTransactionOrNotFound(IOURequestStepParticipantsWithOnyx)); +export default withWritableReportOrNotFound(withFullTransactionOrNotFound(IOURequestStepParticipants)); diff --git a/src/pages/iou/request/step/withFullTransactionOrNotFound.tsx b/src/pages/iou/request/step/withFullTransactionOrNotFound.tsx index 66736dc80b52..8b4519d2758d 100644 --- a/src/pages/iou/request/step/withFullTransactionOrNotFound.tsx +++ b/src/pages/iou/request/step/withFullTransactionOrNotFound.tsx @@ -3,7 +3,7 @@ import {useIsFocused} from '@react-navigation/native'; import type {ComponentType, ForwardedRef, RefAttributes} from 'react'; import React, {forwardRef} from 'react'; import type {OnyxEntry} from 'react-native-onyx'; -import {withOnyx} from 'react-native-onyx'; +import {useOnyx} from 'react-native-onyx'; import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; import getComponentDisplayName from '@libs/getComponentDisplayName'; import * as IOUUtils from '@libs/IOUUtils'; @@ -38,14 +38,24 @@ type MoneyRequestRouteName = | typeof SCREENS.MONEY_REQUEST.STEP_SEND_FROM | typeof SCREENS.MONEY_REQUEST.STEP_COMPANY_INFO; -type Route = RouteProp; +type Route = RouteProp; -type WithFullTransactionOrNotFoundProps = WithFullTransactionOrNotFoundOnyxProps & {route: Route}; +type WithFullTransactionOrNotFoundProps = WithFullTransactionOrNotFoundOnyxProps & { + route: Route; +}; -export default function , TRef>(WrappedComponent: ComponentType>) { +export default function , TRef>( + WrappedComponent: ComponentType>, +): React.ComponentType & RefAttributes> { // eslint-disable-next-line rulesdir/no-negated-variables - function WithFullTransactionOrNotFound(props: TProps, ref: ForwardedRef) { - const transactionID = props.transaction?.transactionID; + function WithFullTransactionOrNotFound(props: Omit, ref: ForwardedRef) { + const {route} = props; + const transactionID = route.params.transactionID ?? -1; + const userAction = 'action' in route.params && route.params.action ? route.params.action : CONST.IOU.ACTION.CREATE; + + const shouldUseTransactionDraft = IOUUtils.shouldUseTransactionDraft(userAction); + const [transaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`); + const [transactionDraft] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`); const isFocused = useIsFocused(); @@ -55,31 +65,19 @@ export default function ; } - return ( ); } WithFullTransactionOrNotFound.displayName = `withFullTransactionOrNotFound(${getComponentDisplayName(WrappedComponent)})`; - // eslint-disable-next-line deprecation/deprecation - return withOnyx, WithFullTransactionOrNotFoundOnyxProps>({ - transaction: { - key: ({route}) => { - const transactionID = route.params.transactionID ?? -1; - const userAction = 'action' in route.params && route.params.action ? route.params.action : CONST.IOU.ACTION.CREATE; - if (IOUUtils.shouldUseTransactionDraft(userAction)) { - return `${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}` as `${typeof ONYXKEYS.COLLECTION.TRANSACTION}${string}`; - } - return `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`; - }, - }, - })(forwardRef(WithFullTransactionOrNotFound)); + return forwardRef(WithFullTransactionOrNotFound); } export type {WithFullTransactionOrNotFoundProps}; diff --git a/src/pages/settings/Security/AddDelegate/DelegateMagicCodeModal.tsx b/src/pages/settings/Security/AddDelegate/DelegateMagicCodeModal.tsx index 4f6770bd98ff..e466b862ae9a 100644 --- a/src/pages/settings/Security/AddDelegate/DelegateMagicCodeModal.tsx +++ b/src/pages/settings/Security/AddDelegate/DelegateMagicCodeModal.tsx @@ -23,16 +23,17 @@ function DelegateMagicCodeModal({login, role, onClose, isValidateCodeActionModal const [account] = useOnyx(ONYXKEYS.ACCOUNT); const currentDelegate = account?.delegatedAccess?.delegates?.find((d) => d.email === login); - const validateLoginError = ErrorUtils.getLatestErrorField(currentDelegate, 'addDelegate'); + const addDelegateErrors = account?.delegatedAccess?.errorFields?.addDelegate?.[login]; + const validateLoginError = ErrorUtils.getLatestError(addDelegateErrors); useEffect(() => { - if (!currentDelegate || !!currentDelegate.pendingFields?.email || !!currentDelegate.errorFields?.addDelegate) { + if (!currentDelegate || !!currentDelegate.pendingFields?.email || !!addDelegateErrors) { return; } // Dismiss modal on successful magic code verification Navigation.navigate(ROUTES.SETTINGS_SECURITY); - }, [login, currentDelegate, role]); + }, [login, currentDelegate, role, addDelegateErrors]); const onBackButtonPress = () => { onClose?.(); @@ -42,7 +43,7 @@ function DelegateMagicCodeModal({login, role, onClose, isValidateCodeActionModal if (!validateLoginError) { return; } - Delegate.clearAddDelegateErrors(currentDelegate?.email ?? '', 'addDelegate'); + Delegate.clearDelegateErrorsByField(currentDelegate?.email ?? '', 'addDelegate'); }; return ( diff --git a/src/pages/settings/Security/AddDelegate/UpdateDelegateRole/UpdateDelegateMagicCodePage.tsx b/src/pages/settings/Security/AddDelegate/UpdateDelegateRole/UpdateDelegateMagicCodePage.tsx index 2c1bc55e0e92..3bc82e8d7e65 100644 --- a/src/pages/settings/Security/AddDelegate/UpdateDelegateRole/UpdateDelegateMagicCodePage.tsx +++ b/src/pages/settings/Security/AddDelegate/UpdateDelegateRole/UpdateDelegateMagicCodePage.tsx @@ -28,15 +28,16 @@ function UpdateDelegateMagicCodePage({route}: UpdateDelegateMagicCodePageProps) const validateCodeFormRef = useRef(null); const currentDelegate = account?.delegatedAccess?.delegates?.find((d) => d.email === login); + const updateDelegateErrors = account?.delegatedAccess?.errorFields?.addDelegate?.[login]; useEffect(() => { - if (!currentDelegate || !!currentDelegate.pendingFields?.role || !!currentDelegate.errorFields?.updateDelegateRole) { + if (!currentDelegate || !!currentDelegate.pendingFields?.role || !!updateDelegateErrors) { return; } // Dismiss modal on successful magic code verification Navigation.dismissModal(); - }, [login, currentDelegate, role]); + }, [login, currentDelegate, role, updateDelegateErrors]); const onBackButtonPress = () => { Navigation.goBack(ROUTES.SETTINGS_UPDATE_DELEGATE_ROLE.getRoute(login, role)); diff --git a/src/pages/settings/Security/AddDelegate/UpdateDelegateRole/ValidateCodeForm/BaseValidateCodeForm.tsx b/src/pages/settings/Security/AddDelegate/UpdateDelegateRole/ValidateCodeForm/BaseValidateCodeForm.tsx index 4c07803ef0e3..7c35d1478eb2 100644 --- a/src/pages/settings/Security/AddDelegate/UpdateDelegateRole/ValidateCodeForm/BaseValidateCodeForm.tsx +++ b/src/pages/settings/Security/AddDelegate/UpdateDelegateRole/ValidateCodeForm/BaseValidateCodeForm.tsx @@ -66,7 +66,8 @@ function BaseValidateCodeForm({autoComplete = 'one-time-code', innerRef = () => const focusTimeoutRef = useRef(null); const currentDelegate = account?.delegatedAccess?.delegates?.find((d) => d.email === delegate); - const validateLoginError = ErrorUtils.getLatestErrorField(currentDelegate, 'updateDelegateRole'); + const errorFields = account?.delegatedAccess?.errorFields ?? {}; + const validateLoginError = ErrorUtils.getLatestError(errorFields.updateDelegateRole?.[currentDelegate?.email ?? '']); const shouldDisableResendValidateCode = !!isOffline || currentDelegate?.isLoading; @@ -127,7 +128,7 @@ function BaseValidateCodeForm({autoComplete = 'one-time-code', innerRef = () => setValidateCode(text); setFormError({}); if (validateLoginError) { - Delegate.clearAddDelegateErrors(currentDelegate?.email ?? '', 'updateDelegateRole'); + Delegate.clearDelegateErrorsByField(currentDelegate?.email ?? '', 'updateDelegateRole'); } }, [currentDelegate?.email, validateLoginError], diff --git a/src/pages/settings/Security/SecuritySettingsPage.tsx b/src/pages/settings/Security/SecuritySettingsPage.tsx index ec82000d06c8..cd8e7c14d882 100644 --- a/src/pages/settings/Security/SecuritySettingsPage.tsx +++ b/src/pages/settings/Security/SecuritySettingsPage.tsx @@ -21,12 +21,11 @@ import Section from '@components/Section'; import Text from '@components/Text'; import TextLink from '@components/TextLink'; import useLocalize from '@hooks/useLocalize'; -import usePermissions from '@hooks/usePermissions'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; import useWaitForNavigation from '@hooks/useWaitForNavigation'; import useWindowDimensions from '@hooks/useWindowDimensions'; -import {clearAddDelegateErrors, removeDelegate} from '@libs/actions/Delegate'; +import {clearDelegateErrorsByField, removeDelegate} from '@libs/actions/Delegate'; import * as ErrorUtils from '@libs/ErrorUtils'; import getClickedTargetLocation from '@libs/getClickedTargetLocation'; import {formatPhoneNumber} from '@libs/LocalePhoneNumber'; @@ -46,7 +45,6 @@ function SecuritySettingsPage() { const {translate} = useLocalize(); const waitForNavigate = useWaitForNavigation(); const {shouldUseNarrowLayout} = useResponsiveLayout(); - const {canUseNewDotCopilot} = usePermissions(); const {windowWidth} = useWindowDimensions(); const personalDetails = usePersonalDetails(); @@ -56,6 +54,7 @@ function SecuritySettingsPage() { const [shouldShowDelegatePopoverMenu, setShouldShowDelegatePopoverMenu] = useState(false); const [shouldShowRemoveDelegateModal, setShouldShowRemoveDelegateModal] = useState(false); const [selectedDelegate, setSelectedDelegate] = useState(); + const errorFields = account?.delegatedAccess?.errorFields ?? {}; const [anchorPosition, setAnchorPosition] = useState({ anchorPositionHorizontal: 0, @@ -136,9 +135,10 @@ function SecuritySettingsPage() { () => delegates .filter((d) => !d.optimisticAccountID) - .map(({email, role, pendingAction, errorFields, pendingFields}) => { + .map(({email, role, pendingAction, pendingFields}) => { const personalDetail = getPersonalDetailByEmail(email); - const error = ErrorUtils.getLatestErrorField({errorFields}, 'addDelegate'); + const addDelegateErrors = errorFields?.addDelegate?.[email]; + const error = ErrorUtils.getLatestError(addDelegateErrors); const onPress = (e: GestureResponderEvent | KeyboardEvent) => { if (isEmptyObject(pendingAction)) { @@ -171,14 +171,14 @@ function SecuritySettingsPage() { shouldShowRightIcon: true, pendingAction, shouldForceOpacity: !!pendingAction, - onPendingActionDismiss: () => clearAddDelegateErrors(email, 'addDelegate'), + onPendingActionDismiss: () => clearDelegateErrorsByField(email, 'addDelegate'), error, onPress, }; }), // eslint-disable-next-line react-compiler/react-compiler // eslint-disable-next-line react-hooks/exhaustive-deps - [delegates, translate, styles, personalDetails], + [delegates, translate, styles, personalDetails, errorFields], ); const delegatorMenuItems: MenuItemProps[] = useMemo( @@ -236,50 +236,48 @@ function SecuritySettingsPage() { shouldUseSingleExecution /> - {!!canUseNewDotCopilot && ( - -
( - - {translate('delegate.copilotDelegatedAccessDescription')} - - {translate('common.learnMore')} - - - )} - isCentralPane - subtitleMuted - titleStyles={styles.accountSettingsSectionTitle} - childrenStyles={styles.pt5} - > - {hasDelegates && ( - <> - {translate('delegate.membersCanAccessYourAccount')} - - - )} - {!isActingAsDelegate && ( - Navigation.navigate(ROUTES.SETTINGS_ADD_DELEGATE)} - shouldShowRightIcon - wrapperStyle={[styles.sectionMenuItemTopDescription, styles.mb6]} - /> - )} - {hasDelegators && ( - <> - {translate('delegate.youCanAccessTheseAccounts')} - - - )} -
-
- )} + +
( + + {translate('delegate.copilotDelegatedAccessDescription')} + + {translate('common.learnMore')} + + + )} + isCentralPane + subtitleMuted + titleStyles={styles.accountSettingsSectionTitle} + childrenStyles={styles.pt5} + > + {hasDelegates && ( + <> + {translate('delegate.membersCanAccessYourAccount')} + + + )} + {!isActingAsDelegate && ( + Navigation.navigate(ROUTES.SETTINGS_ADD_DELEGATE)} + shouldShowRightIcon + wrapperStyle={[styles.sectionMenuItemTopDescription, styles.mb6]} + /> + )} + {hasDelegators && ( + <> + {translate('delegate.youCanAccessTheseAccounts')} + + + )} +
+
} diff --git a/src/pages/settings/Wallet/PaymentMethodList.tsx b/src/pages/settings/Wallet/PaymentMethodList.tsx index 23d0b5ab6550..676083f31004 100644 --- a/src/pages/settings/Wallet/PaymentMethodList.tsx +++ b/src/pages/settings/Wallet/PaymentMethodList.tsx @@ -9,8 +9,6 @@ import type {ValueOf} from 'type-fest'; import type {RenderSuggestionMenuItemProps} from '@components/AutoCompleteSuggestions/types'; import Button from '@components/Button'; import FormAlertWrapper from '@components/FormAlertWrapper'; -import getBankIcon from '@components/Icon/BankIcons'; -import type {BankName} from '@components/Icon/BankIconsUtils'; import * as Expensicons from '@components/Icon/Expensicons'; import MenuItem from '@components/MenuItem'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; @@ -30,7 +28,7 @@ import * as PaymentMethods from '@userActions/PaymentMethods'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import type {AccountData} from '@src/types/onyx'; +import type {AccountData, CompanyCardFeed} from '@src/types/onyx'; import type {BankIcon} from '@src/types/onyx/Bank'; import type {Errors} from '@src/types/onyx/OnyxCommon'; import type PaymentMethod from '@src/types/onyx/PaymentMethod'; @@ -223,12 +221,12 @@ function PaymentMethodList({ const assignedCardsGrouped: PaymentMethodItem[] = []; assignedCardsSorted.forEach((card) => { - const icon = getBankIcon({bankName: card.bank as BankName, isCard: true, styles}); + const icon = CardUtils.getCardFeedIcon(card.bank as CompanyCardFeed); if (!CardUtils.isExpensifyCard(card.cardID)) { assignedCardsGrouped.push({ key: card.cardID.toString(), - title: card.bank, + title: card.cardName, description: getDescriptionForPolicyDomainCard(card.domainName), shouldShowRightIcon: false, interactive: false, @@ -238,7 +236,9 @@ function PaymentMethodList({ card.fraud === CONST.EXPENSIFY_CARD.FRAUD_TYPES.DOMAIN || card.fraud === CONST.EXPENSIFY_CARD.FRAUD_TYPES.INDIVIDUAL ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined, - ...icon, + icon, + iconStyles: [styles.assignedCardsIconContainer], + iconSize: variables.iconSizeExtraLarge, }); return; } @@ -275,7 +275,10 @@ function PaymentMethodList({ card.fraud === CONST.EXPENSIFY_CARD.FRAUD_TYPES.DOMAIN || card.fraud === CONST.EXPENSIFY_CARD.FRAUD_TYPES.INDIVIDUAL ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined, - ...icon, + icon, + iconStyles: [styles.assignedCardsIconContainer], + iconWidth: variables.bankCardWidth, + iconHeight: variables.bankCardHeight, }); }); return assignedCardsGrouped; diff --git a/src/pages/workspace/WorkspacesListPage.tsx b/src/pages/workspace/WorkspacesListPage.tsx index 82503134b09e..db21700a0c47 100755 --- a/src/pages/workspace/WorkspacesListPage.tsx +++ b/src/pages/workspace/WorkspacesListPage.tsx @@ -384,10 +384,7 @@ function WorkspacesListPage() { shouldDisplaySearchRouter onBackButtonPress={() => Navigation.goBack()} icon={Illustrations.BigRocket} - > - {!shouldUseNarrowLayout && getHeaderButton()} -
- {shouldUseNarrowLayout && {getHeaderButton()}} + /> ({item, onSelectRo return ( ): FormInputErrors => - ValidationUtils.getFieldRequiredErrors(values, [INPUT_IDS.NAME]); + const validate = (values: FormOnyxValues): FormInputErrors => { + const errors = ValidationUtils.getFieldRequiredErrors(values, [INPUT_IDS.NAME]); + const length = values.name.length; + if (length > CONST.STANDARD_LENGTH_LIMIT) { + ErrorUtils.addErrorMessage(errors, INPUT_IDS.NAME, translate('common.error.characterLimitExceedCounter', {length, limit: CONST.STANDARD_LENGTH_LIMIT})); + } + return errors; + }; return ( Navigation.goBack(ROUTES.WORKSPACE_COMPANY_CARD_DETAILS.getRoute(policyID, cardID, bank))} /> + {translate('workspace.moreFeatures.companyCards.giveItNameInstruction')} diff --git a/src/pages/workspace/companyCards/WorkspaceCompanyCardsListHeaderButtons.tsx b/src/pages/workspace/companyCards/WorkspaceCompanyCardsListHeaderButtons.tsx index 4b8f34897076..1d5f2bd6dde5 100644 --- a/src/pages/workspace/companyCards/WorkspaceCompanyCardsListHeaderButtons.tsx +++ b/src/pages/workspace/companyCards/WorkspaceCompanyCardsListHeaderButtons.tsx @@ -37,10 +37,11 @@ function WorkspaceCompanyCardsListHeaderButtons({policyID, selectedFeed}: Worksp const workspaceAccountID = PolicyUtils.getWorkspaceAccountID(policyID); const [cardFeeds] = useOnyx(`${ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER}${workspaceAccountID}`); const shouldChangeLayout = isMediumScreenWidth || shouldUseNarrowLayout; - const feedName = cardFeeds?.settings?.companyCardNicknames?.[selectedFeed] ?? CardUtils.getCardFeedName(selectedFeed); + const feedName = CardUtils.getCardFeedName(selectedFeed); const formattedFeedName = translate('workspace.companyCards.feedName', {feedName}); const isCustomFeed = CONST.COMPANY_CARD.FEED_BANK_NAME.MASTER_CARD === selectedFeed || CONST.COMPANY_CARD.FEED_BANK_NAME.VISA === selectedFeed || CONST.COMPANY_CARD.FEED_BANK_NAME.AMEX === selectedFeed; + const currentFeedData = cardFeeds?.settings?.companyCards?.[selectedFeed] ?? {pending: true, errors: {}}; return (