diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index f4f6a90ae6db..1446f1e4d851 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -94,7 +94,7 @@ This is a checklist for PR authors. Please make sure to complete all tasks and c - [ ] I followed the guidelines as stated in the [Review Guidelines](https://github.com/Expensify/App/blob/main/contributingGuides/PR_REVIEW_GUIDELINES.md) - [ ] I tested other components that can be impacted by my changes (i.e. if the PR modifies a shared library or component like `Avatar`, I verified the components using `Avatar` are working as expected) - [ ] I verified all code is DRY (the PR doesn't include any logic written more than once, with the exception of tests) -- [ ] I verified any variables that can be defined as constants (ie. in CONST.js or at the top of the file that uses the constant) are defined as such +- [ ] I verified any variables that can be defined as constants (ie. in CONST.ts or at the top of the file that uses the constant) are defined as such - [ ] I verified that if a function's arguments changed that all usages have also been updated correctly - [ ] If any new file was added I verified that: - [ ] The file has a description of what it does and/or why is needed at the top of the file if the code is not self explanatory @@ -109,6 +109,7 @@ This is a checklist for PR authors. Please make sure to complete all tasks and c - [ ] I verified that all the inputs inside a form are aligned with each other. - [ ] I added `Design` label and/or tagged `@Expensify/design` so the design team can review the changes. - [ ] If a new page is added, I verified it's using the `ScrollView` component to make it scrollable when more elements are added to the page. +- [ ] I added [unit tests](https://github.com/Expensify/App/blob/main/tests/README.md) for any new feature or bug fix in this PR to help automatically prevent regressions in this user flow. - [ ] If the `main` branch was merged into this PR after a review, I tested again and verified the outcome was still expected according to the `Test` steps. ### Screenshots/Videos diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 1ceb12a30af5..2cacdf557560 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -114,51 +114,6 @@ jobs: env: BROWSERSTACK: ${{ secrets.BROWSERSTACK }} - submitAndroid: - name: Submit Android app for production review - needs: prep - if: ${{ github.ref == 'refs/heads/production' }} - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Ruby - uses: ruby/setup-ruby@v1.190.0 - with: - bundler-cache: true - - - name: Get Android native version - id: getAndroidVersion - run: echo "VERSION_CODE=$(grep -o 'versionCode\s\+[0-9]\+' android/app/build.gradle | awk '{ print $2 }')" >> "$GITHUB_OUTPUT" - - - name: Decrypt json w/ Google Play credentials - run: gpg --batch --yes --decrypt --passphrase="${{ secrets.LARGE_SECRET_PASSPHRASE }}" --output android-fastlane-json-key.json android-fastlane-json-key.json.gpg - working-directory: android/app - - - name: Submit Android build for review - run: bundle exec fastlane android upload_google_play_production - env: - VERSION: ${{ steps.getAndroidVersion.outputs.VERSION_CODE }} - - - name: Warn deployers if Android production deploy failed - if: ${{ failure() }} - uses: 8398a7/action-slack@v3 - with: - status: custom - custom_payload: | - { - channel: '#deployer', - attachments: [{ - color: "#DB4545", - pretext: ``, - text: `💥 Android production deploy failed. Please manually submit ${{ needs.prep.outputs.APP_VERSION }} in the . 💥`, - }] - } - env: - GITHUB_TOKEN: ${{ github.token }} - SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }} - android_hybrid: name: Build and deploy Android HybridApp needs: prep @@ -431,12 +386,6 @@ jobs: APPLE_DEMO_EMAIL: ${{ secrets.APPLE_DEMO_EMAIL }} APPLE_DEMO_PASSWORD: ${{ secrets.APPLE_DEMO_PASSWORD }} - - name: Submit build for App Store review - if: ${{ fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} - run: bundle exec fastlane ios submit_for_review - env: - VERSION: ${{ steps.getIOSVersion.outputs.IOS_VERSION }} - - name: Upload iOS build to Browser Stack if: ${{ !fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} run: curl -u "$BROWSERSTACK" -X POST "https://api-cloud.browserstack.com/app-live/upload" -F "file=@/Users/runner/work/App/App/New Expensify.ipa" @@ -730,7 +679,7 @@ jobs: name: Post a Slack message when any platform fails to build or deploy runs-on: ubuntu-latest if: ${{ failure() }} - needs: [buildAndroid, uploadAndroid, submitAndroid, android_hybrid, desktop, iOS, iOS_hybrid, web] + needs: [buildAndroid, uploadAndroid, android_hybrid, desktop, iOS, iOS_hybrid, web] steps: - name: Checkout uses: actions/checkout@v4 @@ -745,21 +694,15 @@ jobs: outputs: IS_AT_LEAST_ONE_PLATFORM_DEPLOYED: ${{ steps.checkDeploymentSuccessOnAtLeastOnePlatform.outputs.IS_AT_LEAST_ONE_PLATFORM_DEPLOYED }} IS_ALL_PLATFORMS_DEPLOYED: ${{ steps.checkDeploymentSuccessOnAllPlatforms.outputs.IS_ALL_PLATFORMS_DEPLOYED }} - needs: [buildAndroid, uploadAndroid, submitAndroid, android_hybrid, desktop, iOS, iOS_hybrid, web] + needs: [buildAndroid, uploadAndroid, android_hybrid, desktop, iOS, iOS_hybrid, web] if: ${{ always() }} steps: - name: Check deployment success on at least one platform id: checkDeploymentSuccessOnAtLeastOnePlatform run: | isAtLeastOnePlatformDeployed="false" - if [ ${{ github.ref }} == 'refs/heads/production' ]; then - if [ "${{ needs.submitAndroid.result }}" == "success" ]; then - isAtLeastOnePlatformDeployed="true" - fi - else - if [ "${{ needs.uploadAndroid.result }}" == "success" ]; then - isAtLeastOnePlatformDeployed="true" - fi + if [ "${{ needs.uploadAndroid.result }}" == "success" ]; then + isAtLeastOnePlatformDeployed="true" fi if [ "${{ needs.iOS.result }}" == "success" ] || \ @@ -784,14 +727,8 @@ jobs: isAllPlatformsDeployed="true" fi - if [ ${{ github.ref }} == 'refs/heads/production' ]; then - if [ "${{ needs.submitAndroid.result }}" != "success" ]; then - isAllPlatformsDeployed="false" - fi - else - if [ "${{ needs.uploadAndroid.result }}" != "success" ]; then - isAllPlatformsDeployed="false" - fi + if [ "${{ needs.uploadAndroid.result }}" != "success" ]; then + isAllPlatformsDeployed="false" fi echo "IS_ALL_PLATFORMS_DEPLOYED=$isAllPlatformsDeployed" >> "$GITHUB_OUTPUT" @@ -939,7 +876,7 @@ jobs: name: Post a Slack message when all platforms deploy successfully runs-on: ubuntu-latest if: ${{ always() && fromJSON(needs.checkDeploymentSuccess.outputs.IS_ALL_PLATFORMS_DEPLOYED) }} - needs: [prep, buildAndroid, uploadAndroid, submitAndroid, android_hybrid, desktop, iOS, iOS_hybrid, web, checkDeploymentSuccess, createPrerelease, finalizeRelease] + needs: [prep, buildAndroid, uploadAndroid, android_hybrid, desktop, iOS, iOS_hybrid, web, checkDeploymentSuccess, createPrerelease, finalizeRelease] steps: - name: 'Announces the deploy in the #announce Slack room' uses: 8398a7/action-slack@v3 @@ -993,11 +930,11 @@ jobs: postGithubComments: uses: ./.github/workflows/postDeployComments.yml if: ${{ always() && fromJSON(needs.checkDeploymentSuccess.outputs.IS_AT_LEAST_ONE_PLATFORM_DEPLOYED) }} - needs: [prep, buildAndroid, uploadAndroid, submitAndroid, android_hybrid, desktop, iOS, iOS_hybrid, web, checkDeploymentSuccess, createPrerelease, finalizeRelease] + needs: [prep, buildAndroid, uploadAndroid, android_hybrid, desktop, iOS, iOS_hybrid, web, checkDeploymentSuccess, createPrerelease, finalizeRelease] with: version: ${{ needs.prep.outputs.APP_VERSION }} env: ${{ github.ref == 'refs/heads/production' && 'production' || 'staging' }} - android: ${{ github.ref == 'refs/heads/production' && needs.submitAndroid.result || needs.uploadAndroid.result }} + android: ${{ github.ref == 'refs/heads/production' && needs.uploadAndroid.result }} android_hybrid: ${{ needs.android_hybrid.result }} ios: ${{ needs.iOS.result }} ios_hybrid: ${{ needs.iOS_hybrid.result }} diff --git a/android/app/build.gradle b/android/app/build.gradle index 5d10e3d6f6f8..fdf62160e818 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 1009006606 - versionName "9.0.66-6" + versionCode 1009006700 + versionName "9.0.67-0" // Supported language variants must be declared here to avoid from being removed during the compilation. // This also helps us to not include unnecessary language variants in the APK. resConfigs "en", "es" diff --git a/contributingGuides/PROPOSAL_TEMPLATE.md b/contributingGuides/PROPOSAL_TEMPLATE.md index 8c9fa7968fe2..d5ab0bf4a864 100644 --- a/contributingGuides/PROPOSAL_TEMPLATE.md +++ b/contributingGuides/PROPOSAL_TEMPLATE.md @@ -7,6 +7,9 @@ ### What changes do you think we should make in order to solve the problem? +### What specific scenarios should we cover in automated tests to prevent reintroducing this issue in the future? + + ### What alternative solutions did you explore? (Optional) **Reminder:** Please use plain English, be brief and avoid jargon. Feel free to use images, charts or pseudo-code if necessary. Do not post large multi-line diffs or write walls of text. Do not create PRs unless you have been hired for this job. diff --git a/contributingGuides/REVIEWER_CHECKLIST.md b/contributingGuides/REVIEWER_CHECKLIST.md index 5fc14328f3b4..545c79a95af1 100644 --- a/contributingGuides/REVIEWER_CHECKLIST.md +++ b/contributingGuides/REVIEWER_CHECKLIST.md @@ -30,7 +30,7 @@ - [ ] I verified that this PR follows the guidelines as stated in the [Review Guidelines](https://github.com/Expensify/App/blob/main/contributingGuides/PR_REVIEW_GUIDELINES.md) - [ ] I verified other components that can be impacted by these changes have been tested, and I retested again (i.e. if the PR modifies a shared library or component like `Avatar`, I verified the components using `Avatar` have been tested & I retested again) - [ ] I verified all code is DRY (the PR doesn't include any logic written more than once, with the exception of tests) -- [ ] I verified any variables that can be defined as constants (ie. in CONST.js or at the top of the file that uses the constant) are defined as such +- [ ] I verified any variables that can be defined as constants (ie. in CONST.ts or at the top of the file that uses the constant) are defined as such - [ ] If a new component is created I verified that: - [ ] A similar component doesn't exist in the codebase - [ ] All props are defined accurately and each prop has a `/** comment above it */` @@ -54,6 +54,7 @@ - [ ] I verified that all the inputs inside a form are aligned with each other. - [ ] I added `Design` label and/or tagged `@Expensify/design` so the design team can review the changes. - [ ] If a new page is added, I verified it's using the `ScrollView` component to make it scrollable when more elements are added to the page. +- [ ] For any bug fix or new feature in this PR, I verified that sufficient [unit tests](https://github.com/Expensify/App/blob/main/tests/README.md) are included to prevent regressions in this flow. - [ ] If the `main` branch was merged into this PR after a review, I tested again and verified the outcome was still expected according to the `Test` steps. - [ ] I have checked off every checkbox in the PR reviewer checklist, including those that don't apply to this PR. diff --git a/docs/articles/expensify-classic/expenses/Apply-Tax.md b/docs/articles/expensify-classic/expenses/Apply-Tax.md index c89176bcc0e8..9360962cb2ba 100644 --- a/docs/articles/expensify-classic/expenses/Apply-Tax.md +++ b/docs/articles/expensify-classic/expenses/Apply-Tax.md @@ -28,6 +28,21 @@ To handle these, you can create a single tax that combines both taxes into a sin From the Reports page, you can select Reports and then click **Export To > Tax Report** to generate a CSV containing all the expense information, including the split-out taxes. +## Why is the tax amount different than I expect? + +In Expensify, tax is *inclusive*, meaning it's already part of the total amount shown. + +To determine the inclusive tax from a total price that already includes tax, you can use the following formula: + +### **Tax amount = (Total price x Tax rate) ÷ (1 + Tax Rate)** + +For example, if an item costs $100 and the tax rate is 20%: +Tax amount = (**$100** x .20) ÷ (1 + .**20**) = **$16.67** +This means the tax amount $16.67 is included in the total. + +If you are simply trying to calculate the price before tax, you can use the formula: + +### **Price before tax = (Total price) ÷ (1 + Tax rate)** # Deep Dive diff --git a/ios/NewExpensify.xcodeproj/project.pbxproj b/ios/NewExpensify.xcodeproj/project.pbxproj index cd38fcaaaf6c..cd2598608a0f 100644 --- a/ios/NewExpensify.xcodeproj/project.pbxproj +++ b/ios/NewExpensify.xcodeproj/project.pbxproj @@ -638,6 +638,7 @@ "${PODS_CONFIGURATION_BUILD_DIR}/PromisesObjC/FBLPromises_Privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/RCT-Folly/RCT-Folly_privacy.bundle", "${PODS_ROOT}/../../node_modules/@expensify/react-native-live-markdown/parser/react-native-live-markdown-parser.js", + "${PODS_CONFIGURATION_BUILD_DIR}/RNSVG/RNSVGFilters.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/React-Core/React-Core_privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/React-cxxreact/React-cxxreact_privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/SDWebImage/SDWebImage.bundle", @@ -658,6 +659,7 @@ "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/FBLPromises_Privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RCT-Folly_privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/react-native-live-markdown-parser.js", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNSVGFilters.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-Core_privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-cxxreact_privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/SDWebImage.bundle", @@ -842,6 +844,7 @@ "${PODS_CONFIGURATION_BUILD_DIR}/PromisesObjC/FBLPromises_Privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/RCT-Folly/RCT-Folly_privacy.bundle", "${PODS_ROOT}/../../node_modules/@expensify/react-native-live-markdown/parser/react-native-live-markdown-parser.js", + "${PODS_CONFIGURATION_BUILD_DIR}/RNSVG/RNSVGFilters.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/React-Core/React-Core_privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/React-cxxreact/React-cxxreact_privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/SDWebImage/SDWebImage.bundle", @@ -862,6 +865,7 @@ "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/FBLPromises_Privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RCT-Folly_privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/react-native-live-markdown-parser.js", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNSVGFilters.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-Core_privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-cxxreact_privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/SDWebImage.bundle", diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index 2cd9c81c19ca..dd0f2cbb2734 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -19,7 +19,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 9.0.66 + 9.0.67 CFBundleSignature ???? CFBundleURLTypes @@ -40,7 +40,7 @@ CFBundleVersion - 9.0.66.6 + 9.0.67.0 FullStory OrgId diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index 57ba616450b6..51397b433eb2 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -15,10 +15,10 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 9.0.66 + 9.0.67 CFBundleSignature ???? CFBundleVersion - 9.0.66.6 + 9.0.67.0 diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index 27a481ab98ef..14b3aceba0f9 100644 --- a/ios/NotificationServiceExtension/Info.plist +++ b/ios/NotificationServiceExtension/Info.plist @@ -11,9 +11,9 @@ CFBundleName $(PRODUCT_NAME) CFBundleShortVersionString - 9.0.66 + 9.0.67 CFBundleVersion - 9.0.66.6 + 9.0.67.0 NSExtension NSExtensionPointIdentifier diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 5ea5b19896e4..21633b432c12 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1722,7 +1722,7 @@ PODS: - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - Yoga - - react-native-keyboard-controller (1.14.1): + - react-native-keyboard-controller (1.14.4): - DoubleConversion - glog - hermes-engine @@ -2391,7 +2391,7 @@ PODS: - RNGoogleSignin (10.0.1): - GoogleSignIn (~> 7.0) - React-Core - - RNLiveMarkdown (0.1.183): + - RNLiveMarkdown (0.1.187): - DoubleConversion - glog - hermes-engine @@ -2411,9 +2411,9 @@ PODS: - ReactCodegen - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - - RNLiveMarkdown/newarch (= 0.1.183) + - RNLiveMarkdown/newarch (= 0.1.187) - Yoga - - RNLiveMarkdown/newarch (0.1.183): + - RNLiveMarkdown/newarch (0.1.187): - DoubleConversion - glog - hermes-engine @@ -3236,7 +3236,7 @@ SPEC CHECKSUMS: react-native-geolocation: b9bd12beaf0ebca61a01514517ca8455bd26fa06 react-native-image-picker: f8a13ff106bcc7eb00c71ce11fdc36aac2a44440 react-native-key-command: aae312752fcdfaa2240be9a015fc41ce54087546 - react-native-keyboard-controller: 902c07f41a415b632583b384427a71770a8b02a3 + react-native-keyboard-controller: 97bb7b48fa427c7455afdc8870c2978efd9bfa3a react-native-launch-arguments: 5f41e0abf88a15e3c5309b8875d6fd5ac43df49d react-native-netinfo: fb5112b1fa754975485884ae85a3fb6a684f49d5 react-native-pager-view: c64a744211a46202619a77509f802765d1659dba @@ -3286,7 +3286,7 @@ SPEC CHECKSUMS: RNFS: 4ac0f0ea233904cb798630b3c077808c06931688 RNGestureHandler: 8781e2529230a1bc3ea8d75e5c3cd071b6c6aed7 RNGoogleSignin: ccaa4a81582cf713eea562c5dd9dc1961a715fd0 - RNLiveMarkdown: fa9c6451960d09209bb5698745a0a66330ec53cc + RNLiveMarkdown: 8338447b39fcd86596c74b9e0e9509e365a2dd3b RNLocalize: d4b8af4e442d4bcca54e68fc687a2129b4d71a81 rnmapbox-maps: 460d6ff97ae49c7d5708c3212c6521697c36a0c4 RNPermissions: 0b1429b55af59d1d08b75a8be2459f65a8ac3f28 diff --git a/package-lock.json b/package-lock.json index a58e33023eef..0b228ecc6d18 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,17 +1,17 @@ { "name": "new.expensify", - "version": "9.0.66-6", + "version": "9.0.67-0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "9.0.66-6", + "version": "9.0.67-0", "hasInstallScript": true, "license": "MIT", "dependencies": { "@dotlottie/react-player": "^1.6.3", - "@expensify/react-native-live-markdown": "0.1.183", + "@expensify/react-native-live-markdown": "0.1.187", "@expo/metro-runtime": "~3.2.3", "@firebase/app": "^0.10.10", "@firebase/performance": "^0.6.8", @@ -95,7 +95,7 @@ "react-native-launch-arguments": "^4.0.2", "react-native-localize": "^2.2.6", "react-native-modal": "^13.0.0", - "react-native-onyx": "2.0.81", + "react-native-onyx": "2.0.82", "react-native-pager-view": "6.5.0", "react-native-pdf": "6.7.3", "react-native-performance": "^5.1.0", @@ -3632,14 +3632,14 @@ } }, "node_modules/@expensify/react-native-live-markdown": { - "version": "0.1.183", - "resolved": "https://registry.npmjs.org/@expensify/react-native-live-markdown/-/react-native-live-markdown-0.1.183.tgz", - "integrity": "sha512-egxknos7ghe4M5Z2rK7DvphcaxQBdxyppu5N2tdCVc/3oPO2ZtBNjDjtksqywC12wPtIYgHSgxrzvLEfbh5skw==", + "version": "0.1.187", + "resolved": "https://registry.npmjs.org/@expensify/react-native-live-markdown/-/react-native-live-markdown-0.1.187.tgz", + "integrity": "sha512-bw+dfhRN31u2xfG8LCI3e28g5EG/BfkyX1EqjPBRQlDZo4fZsdA61UFW6P8Y4rHlqspjYXJ0vk4ctECRWYl4Yg==", "license": "MIT", "workspaces": [ - "parser", - "example", - "WebExample" + "./parser", + "./example", + "./WebExample" ], "engines": { "node": ">= 18.0.0" @@ -35765,9 +35765,9 @@ } }, "node_modules/react-native-onyx": { - "version": "2.0.81", - "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-2.0.81.tgz", - "integrity": "sha512-EwBqruX4lLnlk3KyZp4bst/voekLJFus7UhtvKmDuqR2Iz/FremwE04JW6YxGyc7C6KpbQrCFdWg/oF9ptRAtg==", + "version": "2.0.82", + "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-2.0.82.tgz", + "integrity": "sha512-12+NgkC4fOeGu2J6s985NKUuLHP4aijBhpE6Us5IfVL+9dwxr/KqUVgV00OzXtYAABcWcpMC5PrvESqe8T5Iyw==", "license": "MIT", "dependencies": { "ascii-table": "0.0.9", diff --git a/package.json b/package.json index c82882a2c9cf..2b0c98927535 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "9.0.66-6", + "version": "9.0.67-0", "author": "Expensify, Inc.", "homepage": "https://new.expensify.com", "description": "New Expensify is the next generation of Expensify: a reimagination of payments based atop a foundation of chat.", @@ -68,7 +68,7 @@ }, "dependencies": { "@dotlottie/react-player": "^1.6.3", - "@expensify/react-native-live-markdown": "0.1.183", + "@expensify/react-native-live-markdown": "0.1.187", "@expo/metro-runtime": "~3.2.3", "@firebase/app": "^0.10.10", "@firebase/performance": "^0.6.8", @@ -152,7 +152,7 @@ "react-native-launch-arguments": "^4.0.2", "react-native-localize": "^2.2.6", "react-native-modal": "^13.0.0", - "react-native-onyx": "2.0.81", + "react-native-onyx": "2.0.82", "react-native-pager-view": "6.5.0", "react-native-pdf": "6.7.3", "react-native-performance": "^5.1.0", diff --git a/src/CONST.ts b/src/CONST.ts index ee70e3b29668..f79b97f7290d 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -3815,8 +3815,8 @@ const CONST = { }, GA: {}, GB: { - regex: /^[A-Z]{1,2}[0-9R][0-9A-Z]?\s*[0-9][A-Z-CIKMOV]{2}$/, - samples: 'LA102UX, BL2F8FX, BD1S9LU, WR4G 6LH', + regex: /^[A-Z]{1,2}[0-9R][0-9A-Z]?\s*([0-9][ABD-HJLNP-UW-Z]{2})?$/, + samples: 'LA102UX, BL2F8FX, BD1S9LU, WR4G 6LH, W1U', }, GD: {}, GE: { @@ -5949,6 +5949,7 @@ const CONST = { MAX_TAX_RATE_INTEGER_PLACES: 4, MAX_TAX_RATE_DECIMAL_PLACES: 4, + MIN_TAX_RATE_DECIMAL_PLACES: 2, DOWNLOADS_PATH: '/Downloads', DOWNLOADS_TIMEOUT: 5000, diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/MentionUserRenderer.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/MentionUserRenderer.tsx index 36586b09e514..96bdf8e9e1e8 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/MentionUserRenderer.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/MentionUserRenderer.tsx @@ -4,9 +4,9 @@ import isEmpty from 'lodash/isEmpty'; import React from 'react'; import {StyleSheet} from 'react-native'; import type {TextStyle} from 'react-native'; +import {useOnyx} from 'react-native-onyx'; import type {CustomRendererProps, TPhrasing, TText} from 'react-native-render-html'; import {TNodeChildrenRenderer} from 'react-native-render-html'; -import {usePersonalDetails} from '@components/OnyxProvider'; import {ShowContextMenuContext, showContextMenuForReport} from '@components/ShowContextMenuContext'; import Text from '@components/Text'; import UserDetailsTooltip from '@components/UserDetailsTooltip'; @@ -20,6 +20,7 @@ import Navigation from '@libs/Navigation/Navigation'; import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils'; import * as ReportUtils from '@libs/ReportUtils'; import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type {Route} from '@src/ROUTES'; import asMutable from '@src/types/utils/asMutable'; @@ -31,7 +32,7 @@ function MentionUserRenderer({style, tnode, TDefaultRenderer, currentUserPersona const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const htmlAttribAccountID = tnode.attributes.accountid; - const personalDetails = usePersonalDetails() || CONST.EMPTY_OBJECT; + const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST); const htmlAttributeAccountID = tnode.attributes.accountid; let accountID: number; @@ -56,7 +57,7 @@ function MentionUserRenderer({style, tnode, TDefaultRenderer, currentUserPersona return displayText.split('@').at(0); }; - if (!isEmpty(htmlAttribAccountID)) { + if (!isEmpty(htmlAttribAccountID) && personalDetails?.[htmlAttribAccountID]) { const user = personalDetails[htmlAttribAccountID]; accountID = parseInt(htmlAttribAccountID, 10); mentionDisplayText = LocalePhoneNumber.formatPhoneNumber(user?.login ?? '') || PersonalDetailsUtils.getDisplayNameOrDefault(user); diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index d01b69ed5649..83636ef38828 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -120,10 +120,11 @@ function MoneyReportHeader({policy, report: moneyRequestReport, transactionThrea const isPayAtEndExpense = TransactionUtils.isPayAtEndExpense(transaction); const isArchivedReport = ReportUtils.isArchivedRoomWithID(moneyRequestReport?.reportID); const [archiveReason] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${moneyRequestReport?.reportID ?? '-1'}`, {selector: ReportUtils.getArchiveReason}); + const [transactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS); const getCanIOUBePaid = useCallback( - (onlyShowPayElsewhere = false) => IOU.canIOUBePaid(moneyRequestReport, chatReport, policy, transaction ? [transaction] : undefined, onlyShowPayElsewhere), - [moneyRequestReport, chatReport, policy, transaction], + (onlyShowPayElsewhere = false) => IOU.canIOUBePaid(moneyRequestReport, chatReport, policy, transaction ? [transaction] : undefined, transactionViolations, onlyShowPayElsewhere), + [moneyRequestReport, chatReport, policy, transaction, transactionViolations], ); const canIOUBePaid = useMemo(() => getCanIOUBePaid(), [getCanIOUBePaid]); @@ -135,7 +136,10 @@ function MoneyReportHeader({policy, report: moneyRequestReport, transactionThrea const shouldShowPayButton = canIOUBePaid || onlyShowPayElsewhere; - const shouldShowApproveButton = useMemo(() => IOU.canApproveIOU(moneyRequestReport, policy) && !hasOnlyPendingTransactions, [moneyRequestReport, policy, hasOnlyPendingTransactions]); + const shouldShowApproveButton = useMemo( + () => IOU.canApproveIOU(moneyRequestReport, policy, transactionViolations) && !hasOnlyPendingTransactions, + [moneyRequestReport, policy, hasOnlyPendingTransactions, transactionViolations], + ); const shouldDisableApproveButton = shouldShowApproveButton && !ReportUtils.isAllowedToApproveExpenseReport(moneyRequestReport); diff --git a/src/components/ReportActionItem/ReportPreview.tsx b/src/components/ReportActionItem/ReportPreview.tsx index 5edeffd4dea4..0dbff0fe18e1 100644 --- a/src/components/ReportActionItem/ReportPreview.tsx +++ b/src/components/ReportActionItem/ReportPreview.tsx @@ -330,14 +330,14 @@ function ReportPreview({ const bankAccountRoute = ReportUtils.getBankAccountRoute(chatReport); const getCanIOUBePaid = useCallback( - (onlyShowPayElsewhere = false) => IOU.canIOUBePaid(iouReport, chatReport, policy, allTransactions, onlyShowPayElsewhere), - [iouReport, chatReport, policy, allTransactions], + (onlyShowPayElsewhere = false) => IOU.canIOUBePaid(iouReport, chatReport, policy, allTransactions, transactionViolations, onlyShowPayElsewhere), + [iouReport, chatReport, policy, allTransactions, transactionViolations], ); const canIOUBePaid = useMemo(() => getCanIOUBePaid(), [getCanIOUBePaid]); const onlyShowPayElsewhere = useMemo(() => !canIOUBePaid && getCanIOUBePaid(true), [canIOUBePaid, getCanIOUBePaid]); const shouldShowPayButton = isPaidAnimationRunning || canIOUBePaid || onlyShowPayElsewhere; - const shouldShowApproveButton = useMemo(() => IOU.canApproveIOU(iouReport, policy), [iouReport, policy]); + const shouldShowApproveButton = useMemo(() => IOU.canApproveIOU(iouReport, policy, transactionViolations), [iouReport, policy, transactionViolations]); const shouldDisableApproveButton = shouldShowApproveButton && !ReportUtils.isAllowedToApproveExpenseReport(iouReport); diff --git a/src/components/ReportWelcomeText.tsx b/src/components/ReportWelcomeText.tsx index 3c38c9f4c4a3..1e3ce6119315 100644 --- a/src/components/ReportWelcomeText.tsx +++ b/src/components/ReportWelcomeText.tsx @@ -45,6 +45,7 @@ function ReportWelcomeText({report, policy}: ReportWelcomeTextProps) { const displayNamesWithTooltips = ReportUtils.getDisplayNamesWithTooltips(OptionsListUtils.getPersonalDetailsForAccountIDs(participantAccountIDs, personalDetails), isMultipleParticipant); const welcomeMessage = SidebarUtils.getWelcomeMessage(report, policy); const moneyRequestOptions = ReportUtils.temporary_getMoneyRequestOptions(report, policy, participantAccountIDs); + const canEditReportDescription = ReportUtils.canEditReportDescription(report, policy); const filteredOptions = moneyRequestOptions.filter( (item): item is Exclude => item !== CONST.IOU.TYPE.INVOICE, @@ -123,12 +124,13 @@ function ReportWelcomeText({report, policy}: ReportWelcomeTextProps) { (welcomeMessage?.messageHtml ? ( { - if (!canEditPolicyDescription) { + if (!canEditReportDescription) { return; } - Navigation.navigate(ROUTES.WORKSPACE_PROFILE_DESCRIPTION.getRoute(policy?.id ?? '-1')); + const activeRoute = Navigation.getActiveRoute(); + Navigation.navigate(ROUTES.REPORT_DESCRIPTION.getRoute(report?.reportID ?? '-1', activeRoute)); }} - style={[styles.renderHTML, canEditPolicyDescription ? styles.cursorPointer : styles.cursorText]} + style={[styles.renderHTML, canEditReportDescription ? styles.cursorPointer : styles.cursorText]} accessibilityLabel={translate('reportDescriptionPage.roomDescription')} > @@ -153,8 +155,8 @@ function ReportWelcomeText({report, policy}: ReportWelcomeTextProps) { (welcomeMessage?.messageHtml ? ( { - const activeRoute = Navigation.getReportRHPActiveRoute(); - if (ReportUtils.canEditReportDescription(report, policy)) { + const activeRoute = Navigation.getActiveRoute(); + if (canEditReportDescription) { Navigation.navigate(ROUTES.REPORT_DESCRIPTION.getRoute(report?.reportID ?? '-1', activeRoute)); return; } diff --git a/src/components/Tooltip/EducationalTooltip/BaseEducationalTooltip.tsx b/src/components/Tooltip/EducationalTooltip/BaseEducationalTooltip.tsx index a9481f3cf3b3..b6e313cab45d 100644 --- a/src/components/Tooltip/EducationalTooltip/BaseEducationalTooltip.tsx +++ b/src/components/Tooltip/EducationalTooltip/BaseEducationalTooltip.tsx @@ -2,10 +2,8 @@ import React, {memo, useCallback, useEffect, useRef, useState} from 'react'; import type {LayoutRectangle, NativeSyntheticEvent} from 'react-native'; import GenericTooltip from '@components/Tooltip/GenericTooltip'; import type {EducationalTooltipProps} from '@components/Tooltip/types'; -import onyxSubscribe from '@libs/onyxSubscribe'; -import ONYXKEYS from '@src/ONYXKEYS'; -import type {Modal} from '@src/types/onyx'; import measureTooltipCoordinate from './measureTooltipCoordinate'; +import * as TooltipManager from './TooltipManager'; type LayoutChangeEventWithTarget = NativeSyntheticEvent<{layout: LayoutRectangle; target: HTMLElement}>; @@ -18,30 +16,8 @@ function BaseEducationalTooltip({children, onHideTooltip, shouldRender = false, const [shouldMeasure, setShouldMeasure] = useState(false); const show = useRef<() => void>(); - const [modal, setModal] = useState({ - willAlertModalBecomeVisible: false, - isVisible: false, - }); - - const shouldShow = !modal?.willAlertModalBecomeVisible && !modal?.isVisible && shouldRender; - - useEffect(() => { - if (!shouldRender) { - return; - } - const unsubscribeOnyxModal = onyxSubscribe({ - key: ONYXKEYS.MODAL, - callback: (modalArg) => { - if (modalArg === undefined) { - return; - } - setModal(modalArg); - }, - }); - return () => { - unsubscribeOnyxModal(); - }; - }, [shouldRender]); + const removeActiveTooltipRef = useRef(() => {}); + const removePendingTooltipRef = useRef(() => {}); const didShow = useRef(false); @@ -51,6 +27,7 @@ function BaseEducationalTooltip({children, onHideTooltip, shouldRender = false, } hideTooltipRef.current?.(); onHideTooltip?.(); + removeActiveTooltipRef.current(); }, [onHideTooltip]); useEffect( @@ -70,12 +47,6 @@ function BaseEducationalTooltip({children, onHideTooltip, shouldRender = false, return; } - // If the modal is open, hide the tooltip immediately and clear the timeout - if (!shouldShow) { - closeTooltip(); - return; - } - // Automatically hide tooltip after 5 seconds if shouldAutoDismiss is true const timerID = setTimeout(() => { closeTooltip(); @@ -83,21 +54,25 @@ function BaseEducationalTooltip({children, onHideTooltip, shouldRender = false, return () => { clearTimeout(timerID); }; - }, [shouldAutoDismiss, shouldShow, closeTooltip]); + }, [shouldAutoDismiss, closeTooltip]); useEffect(() => { - if (!shouldMeasure || !shouldShow || didShow.current) { + if (!shouldMeasure || !shouldRender || didShow.current) { return; } // When tooltip is used inside an animated view (e.g. popover), we need to wait for the animation to finish before measuring content. const timerID = setTimeout(() => { + removePendingTooltipRef.current(); show.current?.(); didShow.current = true; + removeActiveTooltipRef.current = TooltipManager.addActiveTooltip(closeTooltip); }, 500); + removePendingTooltipRef.current = TooltipManager.addPendingTooltip(timerID); return () => { + removePendingTooltipRef.current(); clearTimeout(timerID); }; - }, [shouldMeasure, shouldShow]); + }, [shouldMeasure, shouldRender, closeTooltip]); useEffect( () => closeTooltip, diff --git a/src/components/Tooltip/EducationalTooltip/TooltipManager.ts b/src/components/Tooltip/EducationalTooltip/TooltipManager.ts new file mode 100644 index 000000000000..bc5547a520b5 --- /dev/null +++ b/src/components/Tooltip/EducationalTooltip/TooltipManager.ts @@ -0,0 +1,30 @@ +// We store the timeouts for each pending tooltip here. +// We're using the timeout because when a tooltip is used inside an animated view (e.g., popover), +// we need to wait for the animation to finish before measuring content. +const pendingTooltips = new Set(); + +// We store the callback for closing a tooltip here. +const activeTooltips = new Set<() => void>(); + +function addPendingTooltip(timeout: NodeJS.Timeout) { + pendingTooltips.add(timeout); + return () => { + pendingTooltips.delete(timeout); + }; +} + +function addActiveTooltip(closeCallback: () => void) { + activeTooltips.add(closeCallback); + return () => { + activeTooltips.delete(closeCallback); + }; +} + +function cancelPendingAndActiveTooltips() { + pendingTooltips.forEach((timeout) => clearTimeout(timeout)); + pendingTooltips.clear(); + activeTooltips.forEach((closeCallback) => closeCallback()); + activeTooltips.clear(); +} + +export {addPendingTooltip, addActiveTooltip, cancelPendingAndActiveTooltips}; diff --git a/src/languages/en.ts b/src/languages/en.ts index c1067e195985..8eee778148c1 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -3231,9 +3231,8 @@ const translations = { collect: 'Collect', }, companyCards: { - addCompanyCards: 'Add company cards', - selectCardFeed: 'Select card feed', - addCardFeed: 'Add card feed', + addCards: 'Add cards', + selectCards: 'Select cards', addNewCard: { other: 'Other', cardProviders: { @@ -3245,9 +3244,9 @@ const translations = { yourCardProvider: `Who's your card provider?`, whoIsYourBankAccount: 'Who’s your bank?', howDoYouWantToConnect: 'How do you want to connect to your bank?', - learnMoreAboutConnections: { - text: 'Learn more about the ', - linkText: 'connection methods.', + learnMoreAboutOptions: { + text: 'Learn more about these ', + linkText: 'options.', }, customFeedDetails: 'Requires setup with your bank. This is most common for larger companies, and the best option, if you qualify.', directFeedDetails: 'Connect now using your master credentials. This is most common.', @@ -3481,7 +3480,6 @@ const translations = { assignCards: 'Assign cards to the entire team', automaticImport: 'Automatic transaction import', }, - ctaTitle: 'Add company cards', }, disableCardTitle: 'Disable company cards', disableCardPrompt: 'You can’t disable company cards because this feature is in use. Reach out to the Concierge for next steps.', diff --git a/src/languages/es.ts b/src/languages/es.ts index f7af1be45139..04dd9aeb11b7 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -3270,9 +3270,8 @@ const translations = { collect: 'Recopilar', }, companyCards: { - addCompanyCards: 'Agregar tarjetas de empresa', - selectCardFeed: 'Seleccionar feed de tarjetas', - addCardFeed: 'Añadir alimentación de tarjeta', + addCards: 'Añadir tarjetas', + selectCards: 'Seleccionar tarjetas', addNewCard: { other: 'Otros', cardProviders: { @@ -3284,9 +3283,9 @@ const translations = { yourCardProvider: `¿Quién es su proveedor de tarjetas?`, whoIsYourBankAccount: '¿Cuál es tu banco?', howDoYouWantToConnect: '¿Cómo deseas conectarte a tu banco?', - learnMoreAboutConnections: { - text: 'Obtén más información sobre ', - linkText: 'los métodos de conexión.', + learnMoreAboutOptions: { + text: 'Obtén más información sobre estas ', + linkText: 'opciones.', }, customFeedDetails: 'Requiere configuración con tu banco. Esto es más común para empresas grandes, y la mejor opción, si calificas.', directFeedDetails: 'Conéctate ahora usando tus credenciales maestras. Esto es lo más común.', @@ -3523,7 +3522,6 @@ const translations = { assignCards: 'Asignar tarjetas a todo el equipo', automaticImport: 'Importación automática de transacciones', }, - ctaTitle: 'Añadir tarjetas de empresa', }, disableCardTitle: 'Deshabilitar tarjetas de empresa', disableCardPrompt: 'No puedes deshabilitar las tarjetas de empresa porque esta función está en uso. Por favor, contacta a Concierge para los próximos pasos.', @@ -5734,7 +5732,7 @@ const translations = { addPaymentCard: 'Añade tarjeta de pago', enterPaymentCardDetails: 'Introduce los datos de tu tarjeta de pago', security: 'Expensify es PCI-DSS obediente, utiliza cifrado a nivel bancario, y emplea infraestructura redundante para proteger tus datos.', - learnMoreAboutSecurity: 'Conozca más sobre nuestra seguridad.', + learnMoreAboutSecurity: 'Obtén más información sobre nuestra seguridad.', }, subscriptionSettings: { title: 'Configuración de suscripción', diff --git a/src/libs/API/parameters/SetPersonalDetailsAndShipExpensifyCardsParams.ts b/src/libs/API/parameters/SetPersonalDetailsAndShipExpensifyCardsParams.ts index 0ab82ba6b755..7f57658f2016 100644 --- a/src/libs/API/parameters/SetPersonalDetailsAndShipExpensifyCardsParams.ts +++ b/src/libs/API/parameters/SetPersonalDetailsAndShipExpensifyCardsParams.ts @@ -9,6 +9,7 @@ type SetPersonalDetailsAndShipExpensifyCardsParams = { addressCountry: string; addressState: string; dob: string; + validateCode: string; }; export default SetPersonalDetailsAndShipExpensifyCardsParams; diff --git a/src/libs/CardUtils.ts b/src/libs/CardUtils.ts index 4f6bf6036538..211bd8e53c55 100644 --- a/src/libs/CardUtils.ts +++ b/src/libs/CardUtils.ts @@ -279,7 +279,14 @@ function getCardFeedName(feedType: CompanyCardFeed): string { [CONST.COMPANY_CARD.FEED_BANK_NAME.BREX]: 'Brex', }; - return feedNamesMapping[feedType]; + // In existing OldDot setups other variations of feeds could exist, ex: vcf2, vcf3, oauth.americanexpressfdx.com 2003 + const feedKey = (Object.keys(feedNamesMapping) as CompanyCardFeed[]).find((feed) => feedType.startsWith(feed)); + + if (!feedKey) { + return ''; + } + + return feedNamesMapping[feedKey]; } const getBankCardDetailsImage = (bank: ValueOf): IconAsset => { @@ -362,6 +369,16 @@ function getDefaultCardName(cardholder?: string) { return `${cardholder}'s card`; } +function checkIfNewFeedConnected(prevFeedsData: CompanyFeeds, currentFeedsData: CompanyFeeds) { + const prevFeeds = Object.keys(prevFeedsData); + const currentFeeds = Object.keys(currentFeedsData); + + return { + isNewFeedConnected: currentFeeds.length > prevFeeds.length, + newFeed: currentFeeds.find((feed) => !prevFeeds.includes(feed)) as CompanyCardFeed | undefined, + }; +} + export { isExpensifyCard, isCorporateCard, @@ -389,5 +406,6 @@ export { removeExpensifyCardFromCompanyCards, getFilteredCardList, hasOnlyOneCardToAssign, + checkIfNewFeedConnected, getDefaultCardName, }; diff --git a/src/libs/CurrencyUtils.ts b/src/libs/CurrencyUtils.ts index 9b6041a57c0a..7515997d24fd 100644 --- a/src/libs/CurrencyUtils.ts +++ b/src/libs/CurrencyUtils.ts @@ -170,7 +170,8 @@ function convertAmountToDisplayString(amount = 0, currency: string = CONST.CURRE return NumberFormatUtils.format(BaseLocaleListener.getPreferredLocale(), convertedAmount, { style: 'currency', currency, - minimumFractionDigits: CONST.MAX_TAX_RATE_DECIMAL_PLACES, + minimumFractionDigits: CONST.MIN_TAX_RATE_DECIMAL_PLACES, + maximumFractionDigits: CONST.MAX_TAX_RATE_DECIMAL_PLACES, }); } diff --git a/src/libs/DebugUtils.ts b/src/libs/DebugUtils.ts index 671fb03f268b..aa87f28bef4b 100644 --- a/src/libs/DebugUtils.ts +++ b/src/libs/DebugUtils.ts @@ -480,7 +480,6 @@ function validateReportDraftProperty(key: keyof Report, value: string) { case 'isOptimisticReport': case 'isWaitingOnBankAccount': case 'isCancelledIOU': - case 'isHidden': return validateBoolean(value); case 'lastReadSequenceNumber': case 'managerID': @@ -621,7 +620,6 @@ function validateReportDraftProperty(key: keyof Report, value: string) { iouReportID: CONST.RED_BRICK_ROAD_PENDING_ACTION, preexistingReportID: CONST.RED_BRICK_ROAD_PENDING_ACTION, nonReimbursableTotal: CONST.RED_BRICK_ROAD_PENDING_ACTION, - isHidden: CONST.RED_BRICK_ROAD_PENDING_ACTION, pendingChatMembers: CONST.RED_BRICK_ROAD_PENDING_ACTION, fieldList: CONST.RED_BRICK_ROAD_PENDING_ACTION, permissions: CONST.RED_BRICK_ROAD_PENDING_ACTION, diff --git a/src/libs/Navigation/AppNavigator/AuthScreens.tsx b/src/libs/Navigation/AppNavigator/AuthScreens.tsx index 53b8304effa0..92195e12348a 100644 --- a/src/libs/Navigation/AppNavigator/AuthScreens.tsx +++ b/src/libs/Navigation/AppNavigator/AuthScreens.tsx @@ -11,6 +11,7 @@ import {SearchContextProvider} from '@components/Search/SearchContext'; import {useSearchRouterContext} from '@components/Search/SearchRouter/SearchRouterContext'; import SearchRouterModal from '@components/Search/SearchRouter/SearchRouterModal'; import TestToolsModal from '@components/TestToolsModal'; +import * as TooltipManager from '@components/Tooltip/EducationalTooltip/TooltipManager'; import useActiveWorkspace from '@hooks/useActiveWorkspace'; import useOnboardingFlowRouter from '@hooks/useOnboardingFlow'; import usePermissions from '@hooks/usePermissions'; @@ -206,6 +207,8 @@ const RootStack = createCustomStackNavigator(); const modalScreenListeners = { focus: () => { + // Since we don't cancel the tooltip in setModalVisibility, we need to do it here so it will be cancelled when a modal screen is shown. + TooltipManager.cancelPendingAndActiveTooltips(); Modal.setModalVisibility(true); }, blur: () => { diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts index 68b7e66fe50b..ab24f472cdf4 100644 --- a/src/libs/PolicyUtils.ts +++ b/src/libs/PolicyUtils.ts @@ -138,7 +138,8 @@ function getNumericValue(value: number | string, toLocaleDigit: (arg: string) => if (Number.isNaN(numValue)) { return NaN; } - return numValue.toFixed(CONST.CUSTOM_UNITS.RATE_DECIMALS); + // Rounding to 4 decimal places + return parseFloat(numValue.toFixed(CONST.MAX_TAX_RATE_DECIMAL_PLACES)); } /** @@ -170,11 +171,10 @@ function getRateDisplayValue(value: number, toLocaleDigit: (arg: string) => stri } if (withDecimals) { - const decimalPart = numValue.toString().split('.').at(1); - if (decimalPart) { - const fixedDecimalPoints = decimalPart.length > 2 && !decimalPart.endsWith('0') ? 3 : 2; - return Number(numValue).toFixed(fixedDecimalPoints).toString().replace('.', toLocaleDigit('.')); - } + const decimalPart = numValue.toString().split('.').at(1) ?? ''; + // Set the fraction digits to be between 2 and 4 (OD Behavior) + const fractionDigits = Math.min(Math.max(decimalPart.length, CONST.MIN_TAX_RATE_DECIMAL_PLACES), CONST.MAX_TAX_RATE_DECIMAL_PLACES); + return Number(numValue).toFixed(fractionDigits).toString().replace('.', toLocaleDigit('.')); } return numValue.toString().replace('.', toLocaleDigit('.')).substring(0, value.toString().length); diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 952e0c2fe4cc..f2228636bddb 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -329,7 +329,6 @@ type OptimisticChatReport = Pick< | 'writeCapability' | 'avatarUrl' | 'invoiceReceiver' - | 'isHidden' > & { isOptimisticReport: true; }; @@ -1228,6 +1227,8 @@ function isGroupChat(report: OnyxEntry | Partial): boolean { /** * Only returns true if this is the Expensify DM report. + * + * Note that this chat is no longer used for new users. We still need this function for users who have this chat. */ function isSystemChat(report: OnyxEntry): boolean { return getChatType(report) === CONST.REPORT.CHAT_TYPE.SYSTEM; @@ -6518,8 +6519,6 @@ function reasonForReportToBeInOptionList({ !report?.reportID || !report?.type || report?.reportName === undefined || - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - report?.isHidden || (!report?.participants && // We omit sending back participants for chat rooms when searching for reports since they aren't needed to display the results and can get very large. // So we allow showing rooms with no participants–in any other circumstances we should never have these reports with no participants in Onyx. @@ -6652,18 +6651,6 @@ function shouldReportBeInOptionList(params: ShouldReportBeInOptionListParams) { return reasonForReportToBeInOptionList(params) !== null; } -/** - * Returns the system report from the list of reports. - */ -function getSystemChat(): OnyxEntry { - const allReports = ReportConnection.getAllReports(); - if (!allReports) { - return undefined; - } - - return Object.values(allReports ?? {}).find((report) => report?.chatType === CONST.REPORT.CHAT_TYPE.SYSTEM); -} - /** * Attempts to find a report in onyx with the provided list of participants. Does not include threads, task, expense, room, and policy expense chat. */ @@ -7400,7 +7387,6 @@ function getTaskAssigneeChatOnyxData( pendingFields: { createChat: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, }, - isHidden: false, }, }, { @@ -8561,7 +8547,6 @@ export { getRoom, getRootParentReport, getRouteFromLink, - getSystemChat, getTaskAssigneeChatOnyxData, getTransactionDetails, getTransactionReportName, diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index e100fb885fff..489502b1de9d 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -289,7 +289,7 @@ function getAction(data: OnyxTypes.SearchResults['data'], key: string): SearchTr const chatReportRNVP = data[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report?.chatReportID}`] ?? undefined; if ( - IOU.canIOUBePaid(report, chatReport, policy, allReportTransactions, false, chatReportRNVP, invoiceReceiverPolicy) && + IOU.canIOUBePaid(report, chatReport, policy, allReportTransactions, undefined, false, chatReportRNVP, invoiceReceiverPolicy) && !ReportUtils.hasOnlyHeldExpenses(report.reportID, allReportTransactions) ) { return CONST.SEARCH.ACTION_TYPES.PAY; diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts index d6724ab89b41..7e014d37c336 100644 --- a/src/libs/actions/IOU.ts +++ b/src/libs/actions/IOU.ts @@ -6230,9 +6230,11 @@ function getSendMoneyParams( }, ); - if (optimisticChatReportActionsData.value) { + const optimisticChatReportActionsValue = optimisticChatReportActionsData.value as Record; + + if (optimisticChatReportActionsValue) { // Add an optimistic created action to the optimistic chat reportActions data - optimisticChatReportActionsData.value[optimisticCreatedActionForChat.reportActionID] = optimisticCreatedActionForChat; + optimisticChatReportActionsValue[optimisticCreatedActionForChat.reportActionID] = optimisticCreatedActionForChat; } } else { failureData.push({ @@ -6812,6 +6814,7 @@ function sendMoneyWithWallet(report: OnyxEntry, amount: number function canApproveIOU( iouReport: OnyxTypes.OnyxInputOrEntry | SearchReport, policy: OnyxTypes.OnyxInputOrEntry | SearchPolicy, + violations?: OnyxCollection, chatReportRNVP?: OnyxTypes.ReportNameValuePairs, ) { // Only expense reports can be approved @@ -6832,6 +6835,8 @@ function canApproveIOU( const iouSettled = ReportUtils.isSettled(iouReport?.reportID); const reportNameValuePairs = chatReportRNVP ?? ReportUtils.getReportNameValuePairs(iouReport?.reportID); const isArchivedReport = ReportUtils.isArchivedRoom(iouReport, reportNameValuePairs); + const allViolations = violations ?? allTransactionViolations; + const hasViolations = ReportUtils.hasViolations(iouReport?.reportID ?? '-1', allViolations); let isTransactionBeingScanned = false; const reportTransactions = TransactionUtils.getAllReportTransactions(iouReport?.reportID); for (const transaction of reportTransactions) { @@ -6844,7 +6849,7 @@ function canApproveIOU( } } - return isCurrentUserManager && !isOpenExpenseReport && !isApproved && !iouSettled && !isArchivedReport && !isTransactionBeingScanned; + return isCurrentUserManager && !isOpenExpenseReport && !isApproved && !iouSettled && !isArchivedReport && !isTransactionBeingScanned && !hasViolations; } function canIOUBePaid( @@ -6852,6 +6857,7 @@ function canIOUBePaid( chatReport: OnyxTypes.OnyxInputOrEntry | SearchReport, policy: OnyxTypes.OnyxInputOrEntry | SearchPolicy, transactions?: OnyxTypes.Transaction[] | SearchTransaction[], + violations?: OnyxCollection, onlyShowPayElsewhere = false, chatReportRNVP?: OnyxTypes.ReportNameValuePairs, invoiceReceiverPolicy?: SearchPolicy, @@ -6898,7 +6904,9 @@ function canIOUBePaid( const {reimbursableSpend} = ReportUtils.getMoneyRequestSpendBreakdown(iouReport); const isAutoReimbursable = policy?.reimbursementChoice === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES ? false : ReportUtils.canBeAutoReimbursed(iouReport, policy); - const shouldBeApproved = canApproveIOU(iouReport, policy); + const allViolations = violations ?? allTransactionViolations; + const shouldBeApproved = canApproveIOU(iouReport, policy, allViolations); + const hasViolations = ReportUtils.hasViolations(iouReport?.reportID ?? '-1', allViolations); const isPayAtEndExpenseReport = ReportUtils.isPayAtEndExpenseReport(iouReport?.reportID, transactions); return ( @@ -6910,6 +6918,7 @@ function canIOUBePaid( !isChatReportArchived && !isAutoReimbursable && !shouldBeApproved && + !hasViolations && !isPayAtEndExpenseReport ); } @@ -6920,7 +6929,7 @@ function getIOUReportActionToApproveOrPay(chatReport: OnyxEntry { const iouReport = ReportUtils.getReportOrDraftReport(action.childReportID ?? '-1'); const policy = PolicyUtils.getPolicy(iouReport?.policyID); - const shouldShowSettlementButton = canIOUBePaid(iouReport, chatReport, policy) || canApproveIOU(iouReport, policy); + const shouldShowSettlementButton = canIOUBePaid(iouReport, chatReport, policy, undefined, allTransactionViolations) || canApproveIOU(iouReport, policy, allTransactionViolations); return action.childReportID?.toString() !== excludedIOUReportID && action.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW && shouldShowSettlementButton; }); } @@ -7196,6 +7205,30 @@ function unapproveExpenseReport(expenseReport: OnyxEntry) { }, ]; + if (expenseReport.parentReportID && expenseReport.parentReportActionID) { + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport.parentReportID}`, + value: { + [expenseReport.parentReportActionID]: { + childStateNum: CONST.REPORT.STATE_NUM.SUBMITTED, + childStatusNum: CONST.REPORT.STATUS_NUM.SUBMITTED, + }, + }, + }); + + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport.parentReportID}`, + value: { + [expenseReport.parentReportActionID]: { + childStateNum: expenseReport.stateNum, + childStatusNum: expenseReport.statusNum, + }, + }, + }); + } + const parameters: UnapproveExpenseReportParams = { reportID: expenseReport.reportID, reportActionID: optimisticUnapprovedReportAction.reportActionID, @@ -7406,6 +7439,30 @@ function cancelPayment(expenseReport: OnyxEntry, chatReport: O }, ]; + if (expenseReport.parentReportID && expenseReport.parentReportActionID) { + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport.parentReportID}`, + value: { + [expenseReport.parentReportActionID]: { + childStateNum: stateNum, + childStatusNum: statusNum, + }, + }, + }); + + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport.parentReportID}`, + value: { + [expenseReport.parentReportActionID]: { + childStateNum: expenseReport.stateNum, + childStatusNum: expenseReport.statusNum, + }, + }, + }); + } + if (chatReport?.reportID) { failureData.push({ onyxMethod: Onyx.METHOD.MERGE, diff --git a/src/libs/actions/Modal.ts b/src/libs/actions/Modal.ts index 00853e9546d5..1301fab81ca1 100644 --- a/src/libs/actions/Modal.ts +++ b/src/libs/actions/Modal.ts @@ -1,4 +1,5 @@ import Onyx from 'react-native-onyx'; +import * as TooltipManager from '@components/Tooltip/EducationalTooltip/TooltipManager'; import ONYXKEYS from '@src/ONYXKEYS'; const closeModals: Array<(isNavigating?: boolean) => void> = []; @@ -86,6 +87,12 @@ function setDisableDismissOnEscape(disableDismissOnEscape: boolean) { * isPopover indicates that the next open modal is popover or bottom docked */ function willAlertModalBecomeVisible(isVisible: boolean, isPopover = false) { + // We cancel the pending and active tooltips here instead of in setModalVisibility because + // we want to do it when a modal is going to show. If we do it when the modal is fully shown, + // the tooltip in that modal won't show. + if (isVisible) { + TooltipManager.cancelPendingAndActiveTooltips(); + } Onyx.merge(ONYXKEYS.MODAL, {willAlertModalBecomeVisible: isVisible, isPopover}); } diff --git a/src/libs/actions/PersonalDetails.ts b/src/libs/actions/PersonalDetails.ts index f759decda812..94a9dc95e846 100644 --- a/src/libs/actions/PersonalDetails.ts +++ b/src/libs/actions/PersonalDetails.ts @@ -465,7 +465,7 @@ function clearAvatarErrors() { }); } -function updatePersonalDetailsAndShipExpensifyCards(values: FormOnyxValues) { +function updatePersonalDetailsAndShipExpensifyCards(values: FormOnyxValues, validateCode: string) { const parameters: SetPersonalDetailsAndShipExpensifyCardsParams = { legalFirstName: values.legalFirstName?.trim() ?? '', legalLastName: values.legalLastName?.trim() ?? '', @@ -477,6 +477,7 @@ function updatePersonalDetailsAndShipExpensifyCards(values: FormOnyxValues = useRef(null); const values = useMemo(() => getSubstepValues(privatePersonalDetails, draftValues), [privatePersonalDetails, draftValues]); @@ -44,9 +52,7 @@ function MissingPersonalDetailsContent({privatePersonalDetails, draftValues}: Mi if (!values) { return; } - PersonalDetails.updatePersonalDetailsAndShipExpensifyCards(values); - FormActions.clearDraftValues(ONYXKEYS.FORMS.PERSONAL_DETAILS_FORM); - Navigation.goBack(); + setIsValidateCodeActionModalVisible(true); }, [values]); const { @@ -75,6 +81,23 @@ function MissingPersonalDetailsContent({privatePersonalDetails, draftValues}: Mi prevScreen(); }; + const handleValidateCodeEntered = useCallback( + (validateCode: string) => { + PersonalDetails.updatePersonalDetailsAndShipExpensifyCards(values, validateCode); + FormActions.clearDraftValues(ONYXKEYS.FORMS.PERSONAL_DETAILS_FORM); + Navigation.goBack(); + }, + [values], + ); + + const sendValidateCode = () => { + if (validateCodeAction?.validateCodeSent) { + return; + } + + requestValidateCodeAction(); + }; + const handleNextScreen = useCallback(() => { if (isEditing) { goToTheLastStep(); @@ -108,6 +131,17 @@ function MissingPersonalDetailsContent({privatePersonalDetails, draftValues}: Mi screenIndex={screenIndex} personalDetailsValues={values} /> + + {}} + onClose={() => setIsValidateCodeActionModalVisible(false)} + isVisible={isValidateCodeActionModalVisible} + title={translate('cardPage.validateCardTitle')} + descriptionPrimary={translate('cardPage.enterMagicCode', {contactMethod: primaryLogin})} + hasMagicCodeBeenSent={!!validateCodeAction?.validateCodeSent} + /> ); } diff --git a/src/pages/Search/SearchTypeMenu.tsx b/src/pages/Search/SearchTypeMenu.tsx index 6d74ccb46e21..6658e05c298d 100644 --- a/src/pages/Search/SearchTypeMenu.tsx +++ b/src/pages/Search/SearchTypeMenu.tsx @@ -244,7 +244,10 @@ function SearchTypeMenu({queryJSON, searchName}: SearchTypeMenuProps) { const shouldShowSavedSearchesMenuItemTitle = Object.values(savedSearches ?? {}).filter((s) => s.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || isOffline).length > 0; return ( - <> + {typeMenuItems.map((item, index) => { const onPress = singleExecution(() => { @@ -272,16 +275,11 @@ function SearchTypeMenu({queryJSON, searchName}: SearchTypeMenuProps) { {shouldShowSavedSearchesMenuItemTitle && ( <> {translate('search.savedSearchesMenuItemTitle')} - - {renderSavedSearchesSection(savedSearchesMenuItems())} - + {renderSavedSearchesSection(savedSearchesMenuItems())} )} - + ); } diff --git a/src/pages/WorkspaceSwitcherPage/WorkspaceCardCreateAWorkspace.tsx b/src/pages/WorkspaceSwitcherPage/WorkspaceCardCreateAWorkspace.tsx index bf250a063582..ddec6554b738 100644 --- a/src/pages/WorkspaceSwitcherPage/WorkspaceCardCreateAWorkspace.tsx +++ b/src/pages/WorkspaceSwitcherPage/WorkspaceCardCreateAWorkspace.tsx @@ -5,7 +5,6 @@ import Section, {CARD_LAYOUT} from '@components/Section'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import Navigation from '@libs/Navigation/Navigation'; -import ROUTES from '@src/ROUTES'; function WorkspaceCardCreateAWorkspace() { const styles = useThemeStyles(); diff --git a/src/pages/settings/Wallet/WalletPage/WalletPage.tsx b/src/pages/settings/Wallet/WalletPage/WalletPage.tsx index 1fb6558cc4da..930f614d606a 100644 --- a/src/pages/settings/Wallet/WalletPage/WalletPage.tsx +++ b/src/pages/settings/Wallet/WalletPage/WalletPage.tsx @@ -313,9 +313,22 @@ function WalletPage({shouldListenForResize = false}: WalletPageProps) { } } }, [hideDefaultDeleteMenu, paymentMethod.methodID, paymentMethod.selectedPaymentMethodType, bankAccountList, fundList, shouldShowDefaultDeleteMenu]); + // Don't show "Make default payment method" button if it's the only payment method or if it's already the default + const isCurrentPaymentMethodDefault = () => { + const hasMultiplePaymentMethods = PaymentUtils.formatPaymentMethods(bankAccountList ?? {}, fundList ?? {}, styles).length > 1; + if (hasMultiplePaymentMethods) { + if (paymentMethod.formattedSelectedPaymentMethod.type === CONST.PAYMENT_METHODS.PERSONAL_BANK_ACCOUNT) { + return paymentMethod.selectedPaymentMethod.bankAccountID === userWallet?.walletLinkedAccountID; + } + if (paymentMethod.formattedSelectedPaymentMethod.type === CONST.PAYMENT_METHODS.DEBIT_CARD) { + return paymentMethod.selectedPaymentMethod.fundID === userWallet?.walletLinkedAccountID; + } + } + return true; + }; const shouldShowMakeDefaultButton = - !paymentMethod.isSelectedPaymentMethodDefault && + !isCurrentPaymentMethodDefault() && !(paymentMethod.formattedSelectedPaymentMethod.type === CONST.PAYMENT_METHODS.PERSONAL_BANK_ACCOUNT && paymentMethod.selectedPaymentMethod.type === CONST.BANK_ACCOUNT.TYPE.BUSINESS); // Determines whether or not the modal popup is mounted from the bottom of the screen instead of the side mount on Web or Desktop screens diff --git a/src/pages/workspace/companyCards/WorkspaceCompanyCardFeedSelectorPage.tsx b/src/pages/workspace/companyCards/WorkspaceCompanyCardFeedSelectorPage.tsx index b1e31d5bbfa2..1751a1fd9249 100644 --- a/src/pages/workspace/companyCards/WorkspaceCompanyCardFeedSelectorPage.tsx +++ b/src/pages/workspace/companyCards/WorkspaceCompanyCardFeedSelectorPage.tsx @@ -79,7 +79,7 @@ function WorkspaceCompanyCardFeedSelectorPage({route}: WorkspaceCompanyCardFeedS shouldEnableMaxHeight > { CompanyCards.clearAddNewCardFlow(); diff --git a/src/pages/workspace/companyCards/WorkspaceCompanyCardPageEmptyState.tsx b/src/pages/workspace/companyCards/WorkspaceCompanyCardPageEmptyState.tsx index 65f054c4e7ce..cfea0ace8264 100644 --- a/src/pages/workspace/companyCards/WorkspaceCompanyCardPageEmptyState.tsx +++ b/src/pages/workspace/companyCards/WorkspaceCompanyCardPageEmptyState.tsx @@ -44,8 +44,8 @@ function WorkspaceCompanyCardPageEmptyState({policy}: WithPolicyAndFullscreenLoa menuItems={companyCardFeatures} title={translate('workspace.moreFeatures.companyCards.feed.title')} subtitle={translate('workspace.moreFeatures.companyCards.subtitle')} - ctaText={translate('workspace.moreFeatures.companyCards.feed.ctaTitle')} - ctaAccessibilityLabel={translate('workspace.moreFeatures.companyCards.feed.ctaTitle')} + ctaText={translate('workspace.companyCards.addCards')} + ctaAccessibilityLabel={translate('workspace.companyCards.addCards')} onCtaPress={startFlow} illustrationBackgroundColor={colors.blue700} illustration={Illustrations.CompanyCardsEmptyState} diff --git a/src/pages/workspace/companyCards/WorkspaceCompanyCardsPage.tsx b/src/pages/workspace/companyCards/WorkspaceCompanyCardsPage.tsx index 453be1f58a32..1b26e4950ef4 100644 --- a/src/pages/workspace/companyCards/WorkspaceCompanyCardsPage.tsx +++ b/src/pages/workspace/companyCards/WorkspaceCompanyCardsPage.tsx @@ -47,7 +47,7 @@ function WorkspaceCompanyCardPage({route}: WorkspaceCompanyCardPageProps) { }, [policyID, workspaceAccountID]); const {isOffline} = useNetwork({onReconnect: fetchCompanyCards}); - const isLoading = !isOffline && (!cardFeeds || cardFeeds.isLoading); + const isLoading = !isOffline && (!cardFeeds || (cardFeeds.isLoading && !cardsList)); useFocusEffect(fetchCompanyCards); diff --git a/src/pages/workspace/companyCards/addNew/AmexCustomFeed.tsx b/src/pages/workspace/companyCards/addNew/AmexCustomFeed.tsx index 3457da78fc41..d8ce6d6159d5 100644 --- a/src/pages/workspace/companyCards/addNew/AmexCustomFeed.tsx +++ b/src/pages/workspace/companyCards/addNew/AmexCustomFeed.tsx @@ -76,14 +76,14 @@ function AmexCustomFeed() { shouldEnableMaxHeight > {translate('workspace.companyCards.addNewCard.howDoYouWantToConnect')} - {`${translate('workspace.companyCards.addNewCard.learnMoreAboutConnections.text')}`} - {`${translate('workspace.companyCards.addNewCard.learnMoreAboutConnections.linkText')}`} + {`${translate('workspace.companyCards.addNewCard.learnMoreAboutOptions.text')}`} + {`${translate('workspace.companyCards.addNewCard.learnMoreAboutOptions.linkText')}`} CONST.COMPANY_CARDS.BANKS?.[value as keyof typeof CONST.COMPANY_CARDS.BANKS] === bankName); - const feedName = bankKey && bankKey !== CONST.COMPANY_CARDS.BANKS.OTHER ? CONST.COMPANY_CARD.FEED_BANK_NAME?.[bankKey as keyof typeof CONST.COMPANY_CARD.FEED_BANK_NAME] : undefined; - const connectedBank = feedName ? cardFeeds?.settings?.oAuthAccountDetails?.[feedName] : undefined; + const prevFeedsData = usePrevious(cardFeeds?.settings?.oAuthAccountDetails); + const {isNewFeedConnected, newFeed} = useMemo(() => CardUtils.checkIfNewFeedConnected(prevFeedsData ?? {}, cardFeeds?.settings?.oAuthAccountDetails ?? {}), [cardFeeds, prevFeedsData]); const renderLoading = () => ; @@ -60,11 +60,13 @@ function BankConnection({policyID}: BankConnectionStepProps) { if (!url) { return; } - if (feedName && connectedBank && !isEmptyObject(connectedBank)) { - Card.updateSelectedFeed(feedName, policyID ?? '-1'); + if (isNewFeedConnected) { + if (newFeed) { + Card.updateSelectedFeed(newFeed, policyID ?? '-1'); + } Navigation.navigate(ROUTES.WORKSPACE_COMPANY_CARDS.getRoute(policyID ?? '-1')); } - }, [connectedBank, feedName, policyID, url]); + }, [isNewFeedConnected, newFeed, policyID, url]); return ( diff --git a/src/pages/workspace/companyCards/addNew/BankConnection/index.tsx b/src/pages/workspace/companyCards/addNew/BankConnection/index.tsx index 98b19b8efbc7..f2b6a7271225 100644 --- a/src/pages/workspace/companyCards/addNew/BankConnection/index.tsx +++ b/src/pages/workspace/companyCards/addNew/BankConnection/index.tsx @@ -1,4 +1,4 @@ -import React, {useCallback, useEffect} from 'react'; +import React, {useCallback, useEffect, useMemo} from 'react'; import {useOnyx} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; import BlockingView from '@components/BlockingViews/BlockingView'; @@ -8,7 +8,9 @@ import ScreenWrapper from '@components/ScreenWrapper'; import Text from '@components/Text'; import TextLink from '@components/TextLink'; import useLocalize from '@hooks/useLocalize'; +import usePrevious from '@hooks/usePrevious'; import useThemeStyles from '@hooks/useThemeStyles'; +import * as CardUtils from '@libs/CardUtils'; import Navigation from '@libs/Navigation/Navigation'; import * as PolicyUtils from '@libs/PolicyUtils'; import getCurrentUrl from '@navigation/currentUrl'; @@ -18,7 +20,6 @@ import getCompanyCardBankConnection from '@userActions/getCompanyCardBankConnect import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import {isEmptyObject} from '@src/types/utils/EmptyObject'; import openBankConnection from './openBankConnection'; let customWindow: Window | null = null; @@ -34,9 +35,8 @@ function BankConnection({policyID}: BankConnectionStepProps) { const bankName: ValueOf | undefined = addNewCard?.data?.selectedBank; const workspaceAccountID = PolicyUtils.getWorkspaceAccountID(policyID ?? '-1'); const [cardFeeds] = useOnyx(`${ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER}${workspaceAccountID}`); - const bankKey = Object.keys(CONST.COMPANY_CARDS.BANKS).find((value) => CONST.COMPANY_CARDS.BANKS?.[value as keyof typeof CONST.COMPANY_CARDS.BANKS] === bankName); - const feedName = bankKey && bankKey !== CONST.COMPANY_CARDS.BANKS.OTHER ? CONST.COMPANY_CARD.FEED_BANK_NAME?.[bankKey as keyof typeof CONST.COMPANY_CARD.FEED_BANK_NAME] : undefined; - const connectedBank = feedName ? cardFeeds?.settings?.oAuthAccountDetails?.[feedName] : undefined; + const prevFeedsData = usePrevious(cardFeeds?.settings?.oAuthAccountDetails); + const {isNewFeedConnected, newFeed} = useMemo(() => CardUtils.checkIfNewFeedConnected(prevFeedsData ?? {}, cardFeeds?.settings?.oAuthAccountDetails ?? {}), [cardFeeds, prevFeedsData]); const currentUrl = getCurrentUrl(); const isBankConnectionCompleteRoute = currentUrl.includes(ROUTES.BANK_CONNECTION_COMPLETE); @@ -73,9 +73,11 @@ function BankConnection({policyID}: BankConnectionStepProps) { if (!url) { return; } - if (feedName && connectedBank && !isEmptyObject(connectedBank)) { + if (isNewFeedConnected) { customWindow?.close(); - Card.updateSelectedFeed(feedName, policyID ?? '-1'); + if (newFeed) { + Card.updateSelectedFeed(newFeed, policyID ?? '-1'); + } Navigation.navigate(ROUTES.WORKSPACE_COMPANY_CARDS.getRoute(policyID ?? '-1')); return; } @@ -84,12 +86,12 @@ function BankConnection({policyID}: BankConnectionStepProps) { return; } customWindow = openBankConnection(url); - }, [connectedBank, feedName, isBankConnectionCompleteRoute, policyID, url]); + }, [isNewFeedConnected, newFeed, isBankConnectionCompleteRoute, policyID, url]); return ( {translate('workspace.companyCards.addNewCard.whatBankIssuesCard')} diff --git a/src/pages/workspace/companyCards/addNew/CardTypeStep.tsx b/src/pages/workspace/companyCards/addNew/CardTypeStep.tsx index 7fded06b75f6..47f09d71dcfe 100644 --- a/src/pages/workspace/companyCards/addNew/CardTypeStep.tsx +++ b/src/pages/workspace/companyCards/addNew/CardTypeStep.tsx @@ -100,7 +100,7 @@ function CardTypeStep() { shouldEnableMaxHeight > diff --git a/src/pages/workspace/companyCards/addNew/DetailsStep.tsx b/src/pages/workspace/companyCards/addNew/DetailsStep.tsx index 0064303fed7a..46281ca671a8 100644 --- a/src/pages/workspace/companyCards/addNew/DetailsStep.tsx +++ b/src/pages/workspace/companyCards/addNew/DetailsStep.tsx @@ -168,7 +168,7 @@ function DetailsStep({policyID}: DetailsStepProps) { shouldEnableMaxHeight > diff --git a/src/pages/workspace/companyCards/addNew/SelectFeedType.tsx b/src/pages/workspace/companyCards/addNew/SelectFeedType.tsx index 959e7c10f3aa..53b88b2b7295 100644 --- a/src/pages/workspace/companyCards/addNew/SelectFeedType.tsx +++ b/src/pages/workspace/companyCards/addNew/SelectFeedType.tsx @@ -66,14 +66,14 @@ function SelectFeedType() { shouldEnableMaxHeight > {translate('workspace.companyCards.addNewCard.howDoYouWantToConnect')} - {`${translate('workspace.companyCards.addNewCard.learnMoreAboutConnections.text')}`} - {`${translate('workspace.companyCards.addNewCard.learnMoreAboutConnections.linkText')}.`} + {`${translate('workspace.companyCards.addNewCard.learnMoreAboutOptions.text')}`} + {`${translate('workspace.companyCards.addNewCard.learnMoreAboutOptions.linkText')}`} tag.previousTagName === tagName); useEffect(() => { @@ -104,7 +107,6 @@ function TagSettingsPage({route, navigation}: TagSettingsPageProps) { const isThereAnyAccountingConnection = Object.keys(policy?.connections ?? {}).length !== 0; const isMultiLevelTags = PolicyUtils.isMultiLevelTags(policyTags); - const tagApprover = PolicyUtils.getTagApproverRule(policyID, route.params.tagName)?.approver; const shouldShowDeleteMenuItem = !isThereAnyAccountingConnection && !isMultiLevelTags; const workflowApprovalsUnavailable = PolicyUtils.getWorkflowApprovalsUnavailable(policy); @@ -180,7 +182,7 @@ function TagSettingsPage({route, navigation}: TagSettingsPageProps) { {translate('workspace.tags.tagRules')}