diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index f2a3f96b8f6..8652d29ad28 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -28,10 +28,12 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.OS_BOTIFY_TOKEN }} - createTag: + prep: needs: validateActor - if: ${{ github.ref == 'refs/heads/staging' }} + if: ${{ fromJSON(needs.validateActor.outputs.IS_DEPLOYER) }} runs-on: ubuntu-latest + outputs: + APP_VERSION: ${{ steps.getAppVersion.outputs.VERSION }} steps: - name: Checkout uses: actions/checkout@v4 @@ -46,9 +48,14 @@ jobs: OS_BOTIFY_APP_ID: ${{ secrets.OS_BOTIFY_APP_ID }} OS_BOTIFY_PRIVATE_KEY: ${{ secrets.OS_BOTIFY_PRIVATE_KEY }} + - name: Get app version + id: getAppVersion + run: echo "VERSION=$(jq -r .version < package.json)" >> "$GITHUB_OUTPUT" + - name: Create and push tag + if: ${{ github.ref == 'refs/heads/staging' }} run: | - git tag "$(jq -r .version < package.json)" + git tag ${{ steps.getAppVersion.outputs.VERSION }} git push origin --tags # Note: we're updating the checklist before running the deploys and assuming that it will succeed on at least one platform @@ -56,15 +63,16 @@ jobs: name: Create or update deploy checklist uses: ./.github/workflows/createDeployChecklist.yml if: ${{ github.ref == 'refs/heads/staging' }} - needs: createTag + needs: prep secrets: inherit android: # WARNING: getDeployPullRequestList depends on this job name. do not change job name without adjusting that action accordingly name: Build and deploy Android - needs: validateActor - if: ${{ fromJSON(needs.validateActor.outputs.IS_DEPLOYER) }} + needs: prep runs-on: ubuntu-latest-xl + env: + RUBYOPT: '-rostruct' steps: - name: Checkout uses: actions/checkout@v4 @@ -96,16 +104,21 @@ jobs: env: LARGE_SECRET_PASSPHRASE: ${{ secrets.LARGE_SECRET_PASSPHRASE }} - - name: Set version in ENV - run: echo "VERSION_CODE=$(grep -o 'versionCode\s\+[0-9]\+' android/app/build.gradle | awk '{ print $2 }')" >> "$GITHUB_ENV" + - 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_OUTPUTS" - - name: Run Fastlane - run: bundle exec fastlane android ${{ fromJSON(env.SHOULD_DEPLOY_PRODUCTION) && 'production' || 'beta' }} + - name: Build Android app + if: ${{ !fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} + run: bundle exec fastlane android build env: - RUBYOPT: '-rostruct' MYAPP_UPLOAD_STORE_PASSWORD: ${{ secrets.MYAPP_UPLOAD_STORE_PASSWORD }} MYAPP_UPLOAD_KEY_PASSWORD: ${{ secrets.MYAPP_UPLOAD_KEY_PASSWORD }} - VERSION: ${{ env.VERSION_CODE }} + + - name: Upload Android app to Google Play + run: bundle exec fastlane android ${{ fromJSON(env.SHOULD_DEPLOY_PRODUCTION) && 'upload_google_play_production' || 'upload_google_play_internal' }} + env: + VERSION: ${{ steps.getAndroidVersion.outputs.VERSION_CODE }} - name: Upload Android build to Browser Stack if: ${{ !fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} @@ -141,7 +154,7 @@ jobs: attachments: [{ color: "#DB4545", pretext: ``, - text: `πŸ’₯ Android production deploy failed. Please manually submit ${{ env.VERSION }} in the . πŸ’₯`, + text: `πŸ’₯ Android production deploy failed. Please manually submit ${{ needs.prep.outputs.APP_VERSION }} in the . πŸ’₯`, }] } env: @@ -151,8 +164,7 @@ jobs: desktop: # WARNING: getDeployPullRequestList depends on this job name. do not change job name without adjusting that action accordingly name: Build and deploy Desktop - needs: validateActor - if: ${{ fromJSON(needs.validateActor.outputs.IS_DEPLOYER) }} + needs: prep runs-on: macos-14-large steps: - name: Checkout @@ -197,8 +209,7 @@ jobs: iOS: # WARNING: getDeployPullRequestList depends on this job name. do not change job name without adjusting that action accordingly name: Build and deploy iOS - needs: validateActor - if: ${{ fromJSON(needs.validateActor.outputs.IS_DEPLOYER) }} + needs: prep env: DEVELOPER_DIR: /Applications/Xcode_15.2.0.app/Contents/Developer runs-on: macos-13-xlarge @@ -260,17 +271,28 @@ jobs: - name: Set current App version in Env run: echo "VERSION=$(npm run print-version --silent)" >> "$GITHUB_ENV" - - name: Set iOS version in ENV - run: echo "IOS_VERSION=$(echo '${{ env.VERSION }}' | tr '-' '.')" >> "$GITHUB_ENV" + - name: Get iOS native version + id: getIOSVersion + run: echo "IOS_VERSION=$(echo '${{ needs.prep.outputs.APP_VERSION }}' | tr '-' '.')" >> "$GITHUB_OUTPUTS" + + - name: Build iOS release app + if: ${{ !fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} + run: bundle exec fastlane ios build - - name: Run Fastlane - run: bundle exec fastlane ios ${{ fromJSON(env.SHOULD_DEPLOY_PRODUCTION) && 'production' || 'beta' }} + - name: Upload release build to TestFlight + if: ${{ !fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} + run: bundle exec fastlane ios upload_testflight env: APPLE_CONTACT_EMAIL: ${{ secrets.APPLE_CONTACT_EMAIL }} APPLE_CONTACT_PHONE: ${{ secrets.APPLE_CONTACT_PHONE }} APPLE_DEMO_EMAIL: ${{ secrets.APPLE_DEMO_EMAIL }} APPLE_DEMO_PASSWORD: ${{ secrets.APPLE_DEMO_PASSWORD }} - VERSION: ${{ env.IOS_VERSION }} + + - 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) }} @@ -303,7 +325,7 @@ jobs: attachments: [{ color: "#DB4545", pretext: ``, - text: `πŸ’₯ iOS production deploy failed. Please manually submit ${{ env.IOS_VERSION }} in the . πŸ’₯`, + text: `πŸ’₯ iOS production deploy failed. Please manually submit ${{ steps.getIOSVersion.outputs.IOS_VERSION }} in the . πŸ’₯`, }] } env: @@ -313,8 +335,7 @@ jobs: web: # WARNING: getDeployPullRequestList depends on this job name. do not change job name without adjusting that action accordingly name: Build and deploy Web - needs: validateActor - if: ${{ fromJSON(needs.validateActor.outputs.IS_DEPLOYER) }} + needs: prep runs-on: ubuntu-latest-xl steps: - name: Checkout @@ -371,8 +392,8 @@ jobs: run: | sleep 5 DOWNLOADED_VERSION="$(wget -q -O /dev/stdout https://staging.new.expensify.com/version.json | jq -r '.version')" - if [[ '${{ env.VERSION }}' != "$DOWNLOADED_VERSION" ]]; then - echo "Error: deployed version $DOWNLOADED_VERSION does not match local version ${{ env.VERSION }}. Something went wrong..." + if [[ '${{ needs.prep.outputs.APP_VERSION }}' != "$DOWNLOADED_VERSION" ]]; then + echo "Error: deployed version $DOWNLOADED_VERSION does not match local version ${{ needs.prep.outputs.APP_VERSION }}. Something went wrong..." exit 1 fi @@ -381,8 +402,8 @@ jobs: run: | sleep 5 DOWNLOADED_VERSION="$(wget -q -O /dev/stdout https://new.expensify.com/version.json | jq -r '.version')" - if [[ '${{ env.VERSION }}' != "$DOWNLOADED_VERSION" ]]; then - echo "Error: deployed version $DOWNLOADED_VERSION does not match local version ${{ env.VERSION }}. Something went wrong..." + if [[ '${{ needs.prep.outputs.APP_VERSION }}' != "$DOWNLOADED_VERSION" ]]; then + echo "Error: deployed version $DOWNLOADED_VERSION does not match local version ${{ needs.prep.outputs.APP_VERSION }}. Something went wrong..." exit 1 fi @@ -426,8 +447,8 @@ jobs: # Build a version of iOS and Android HybridApp if we are deploying to staging hybridApp: runs-on: ubuntu-latest - needs: validateActor - if: ${{ fromJSON(needs.validateActor.outputs.IS_DEPLOYER) && github.ref == 'refs/heads/staging' }} + needs: prep + if: ${{ github.ref == 'refs/heads/staging' }} steps: - name: Checkout uses: actions/checkout@v4 @@ -443,6 +464,7 @@ jobs: IS_AT_LEAST_ONE_PLATFORM_DEPLOYED: ${{ steps.checkDeploymentSuccess.outputs.IS_AT_LEAST_ONE_PLATFORM_DEPLOYED }} IS_ALL_PLATFORMS_DEPLOYED: ${{ steps.checkDeploymentSuccess.outputs.IS_ALL_PLATFORMS_DEPLOYED }} needs: [android, desktop, iOS, web] + if: ${{ always() }} steps: - name: Check deployment success on at least one platform id: checkDeploymentSuccess @@ -467,23 +489,17 @@ jobs: createPrerelease: runs-on: ubuntu-latest if: ${{ github.ref == 'refs/heads/staging' && fromJSON(needs.checkDeploymentSuccess.outputs.IS_AT_LEAST_ONE_PLATFORM_DEPLOYED) }} - needs: [checkDeploymentSuccess] + needs: [prep, checkDeploymentSuccess] steps: - - name: Checkout staging branch - uses: actions/checkout@v4 - - - name: Get current app version - run: echo "STAGING_VERSION=$(jq -r .version < package.json)" >> "$GITHUB_ENV" - - name: Download all workflow run artifacts uses: actions/download-artifact@v4 - name: πŸš€ Create prerelease πŸš€ run: | - gh release create ${{ env.STAGING_VERSION }} --title ${{ env.STAGING_VERSION }} --generate-notes --prerelease --target staging + gh release create ${{ needs.prep.outputs.APP_VERSION }} --title ${{ needs.prep.outputs.APP_VERSION }} --generate-notes --prerelease --target staging RETRIES=0 MAX_RETRIES=10 - until [[ $(gh release view ${{ env.STAGING_VERSION }}) || $RETRIES -ge $MAX_RETRIES ]]; do + until [[ $(gh release view ${{ needs.prep.outputs.APP_VERSION }}) || $RETRIES -ge $MAX_RETRIES ]]; do echo "release not found, retrying $((MAX_RETRIES - RETRIES++)) times" sleep 1 done @@ -497,14 +513,14 @@ jobs: - name: Upload artifacts to GitHub Release run: | - gh release upload ${{ env.STAGING_VERSION }} \ - ./android-sourcemaps-artifact/index.android.bundle.map#android-sourcemap-${{ env.STAGING_VERSION }} \ + gh release upload ${{ needs.prep.outputs.APP_VERSION }} \ + ./android-sourcemaps-artifact/index.android.bundle.map#android-sourcemap-${{ needs.prep.outputs.APP_VERSION }} \ ./android-build-artifact/app-production-release.aab \ - ./desktop-sourcemaps-artifact/desktop-merged-source-map.js.map#desktop-sourcemap-${{ env.STAGING_VERSION }} \ + ./desktop-sourcemaps-artifact/desktop-merged-source-map.js.map#desktop-sourcemap-${{ needs.prep.outputs.APP_VERSION }} \ ./desktop-build-artifact/NewExpensify.dmg \ - ./ios-sourcemaps-artifact/main.jsbundle.map#ios-sourcemap-${{ env.STAGING_VERSION }} \ + ./ios-sourcemaps-artifact/main.jsbundle.map#ios-sourcemap-${{ needs.prep.outputs.APP_VERSION }} \ ./ios-build-artifact/New\ Expensify.ipa \ - ./web-sourcemaps-artifact/web-merged-source-map.js.map#web-sourcemap-${{ env.STAGING_VERSION }} \ + ./web-sourcemaps-artifact/web-merged-source-map.js.map#web-sourcemap-${{ needs.prep.outputs.APP_VERSION }} \ ./web-build-tar-gz-artifact/webBuild.tar.gz \ ./web-build-zip-artifact/webBuild.zip env: @@ -531,14 +547,8 @@ jobs: finalizeRelease: runs-on: ubuntu-latest if: ${{ github.ref == 'refs/heads/production' && fromJSON(needs.checkDeploymentSuccess.outputs.IS_AT_LEAST_ONE_PLATFORM_DEPLOYED) }} - needs: [checkDeploymentSuccess] + needs: [prep, checkDeploymentSuccess] steps: - - name: Checkout production branch - uses: actions/checkout@v4 - - - name: Get current app version - run: echo "PRODUCTION_VERSION=$(npm run print-version --silent)" >> "$GITHUB_ENV" - - name: Download all workflow run artifacts uses: actions/download-artifact@v4 @@ -549,10 +559,10 @@ jobs: - name: Upload artifacts to GitHub Release run: | - gh release upload ${{ env.STAGING_VERSION }} \ - ./desktop-sourcemaps-artifact/desktop-merged-source-map.js.map#desktop-sourcemap-${{ env.STAGING_VERSION }} \ + gh release upload ${{ needs.prep.outputs.APP_VERSION }} \ + ./desktop-sourcemaps-artifact/desktop-merged-source-map.js.map#desktop-sourcemap-${{ needs.prep.outputs.APP_VERSION }} \ ./desktop-build-artifact/NewExpensify.dmg \ - ./web-sourcemaps-artifact/web-merged-source-map.js.map#web-sourcemap-${{ env.STAGING_VERSION }} \ + ./web-sourcemaps-artifact/web-merged-source-map.js.map#web-sourcemap-${{ needs.prep.outputs.APP_VERSION }} \ ./web-build-tar-gz-artifact/webBuild.tar.gz \ ./web-build-zip-artifact/webBuild.zip env: @@ -561,8 +571,8 @@ jobs: - name: πŸš€ Edit the release to be no longer a prerelease πŸš€ run: | LATEST_RELEASE="$(gh release list --exclude-pre-releases --json tagName,isLatest --jq '.[] | select(.isLatest) | .tagName')" - gh api --method POST /repos/Expensify/App/releases/generate-notes -f "tag_name=${{ env.PRODUCTION_VERSION }}" -f "previous_tag_name=$LATEST_RELEASE" | jq -r '.body' >> releaseNotes.md - gh release edit ${{ env.PRODUCTION_VERSION }} --prerelease=false --latest --notes-file releaseNotes.md + gh api --method POST /repos/Expensify/App/releases/generate-notes -f "tag_name=${{ needs.prep.outputs.APP_VERSION }}" -f "previous_tag_name=$LATEST_RELEASE" | jq -r '.body' >> releaseNotes.md + gh release edit ${{ needs.prep.outputs.APP_VERSION }} --prerelease=false --latest --notes-file releaseNotes.md env: GITHUB_TOKEN: ${{ github.token }} @@ -588,14 +598,8 @@ jobs: name: Post a Slack message when all platforms deploy successfully runs-on: ubuntu-latest if: ${{ fromJSON(needs.checkDeploymentSuccess.outputs.IS_ALL_PLATFORMS_DEPLOYED) }} - needs: [checkDeploymentSuccess, createPrerelease, finalizeRelease] + needs: [prep, checkDeploymentSuccess, createPrerelease, finalizeRelease] steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Set current App version in Env - run: echo "VERSION=$(npm run print-version --silent)" >> "$GITHUB_ENV" - - name: 'Announces the deploy in the #announce Slack room' uses: 8398a7/action-slack@v3 with: @@ -605,7 +609,7 @@ jobs: channel: '#announce', attachments: [{ color: 'good', - text: `πŸŽ‰οΈ Successfully deployed ${process.env.AS_REPO} to ${{ fromJSON(env.SHOULD_DEPLOY_PRODUCTION) && 'production' || 'staging' }} πŸŽ‰οΈ`, + text: `πŸŽ‰οΈ Successfully deployed ${process.env.AS_REPO} to ${{ fromJSON(env.SHOULD_DEPLOY_PRODUCTION) && 'production' || 'staging' }} πŸŽ‰οΈ`, }] } env: @@ -621,7 +625,7 @@ jobs: channel: '#deployer', attachments: [{ color: 'good', - text: `πŸŽ‰οΈ Successfully deployed ${process.env.AS_REPO} to ${{ fromJSON(env.SHOULD_DEPLOY_PRODUCTION) && 'production' || 'staging' }} πŸŽ‰οΈ`, + text: `πŸŽ‰οΈ Successfully deployed ${process.env.AS_REPO} to ${{ fromJSON(env.SHOULD_DEPLOY_PRODUCTION) && 'production' || 'staging' }} πŸŽ‰οΈ`, }] } env: @@ -638,7 +642,7 @@ jobs: channel: '#expensify-open-source', attachments: [{ color: 'good', - text: `πŸŽ‰οΈ Successfully deployed ${process.env.AS_REPO} to production πŸŽ‰οΈ`, + text: `πŸŽ‰οΈ Successfully deployed ${process.env.AS_REPO} to production πŸŽ‰οΈ`, }] } env: @@ -648,8 +652,8 @@ jobs: postGithubComment: name: Post a GitHub comments on all deployed PRs when platforms are done building and deploying runs-on: ubuntu-latest - if: ${{ fromJSON(needs.checkDeploymentSuccess.outputs.IS_AT_LEAST_ONE_PLATFORM_DEPLOYED) }} - needs: [android, desktop, iOS, web, checkDeploymentSuccess, createPrerelease, finalizeRelease] + if: ${{ always() && fromJSON(needs.checkDeploymentSuccess.outputs.IS_AT_LEAST_ONE_PLATFORM_DEPLOYED) }} + needs: [prep, android, desktop, iOS, web, checkDeploymentSuccess, createPrerelease, finalizeRelease] steps: - name: Checkout uses: actions/checkout@v4 @@ -657,14 +661,11 @@ jobs: - name: Setup Node uses: ./.github/actions/composite/setupNode - - name: Set current App version in Env - run: echo "VERSION=$(npm run print-version --silent)" >> "$GITHUB_ENV" - - name: Get Release Pull Request List id: getReleasePRList uses: ./.github/actions/javascript/getDeployPullRequestList with: - TAG: ${{ env.VERSION }} + TAG: ${{ needs.prep.outputs.APP_VERSION }} GITHUB_TOKEN: ${{ secrets.OS_BOTIFY_TOKEN }} IS_PRODUCTION_DEPLOY: ${{ fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} @@ -673,7 +674,7 @@ jobs: with: PR_LIST: ${{ steps.getReleasePRList.outputs.PR_LIST }} IS_PRODUCTION_DEPLOY: ${{ fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} - DEPLOY_VERSION: ${{ env.VERSION }} + DEPLOY_VERSION: ${{ needs.prep.outputs.APP_VERSION }} GITHUB_TOKEN: ${{ secrets.OS_BOTIFY_TOKEN }} ANDROID: ${{ needs.android.result }} DESKTOP: ${{ needs.desktop.result }} diff --git a/.github/workflows/testBuild.yml b/.github/workflows/testBuild.yml index 21f7fcedfe8..f523faf785c 100644 --- a/.github/workflows/testBuild.yml +++ b/.github/workflows/testBuild.yml @@ -10,6 +10,9 @@ on: types: [opened, synchronize, labeled] branches: ['*ci-test/**'] +env: + PULL_REQUEST_NUMBER: ${{ github.event.number || github.event.inputs.PULL_REQUEST_NUMBER }} + jobs: validateActor: runs-on: ubuntu-latest @@ -35,7 +38,6 @@ jobs: echo "The 'Ready to Build' label is not attached to the PR #${{ env.PULL_REQUEST_NUMBER }}" fi env: - PULL_REQUEST_NUMBER: ${{ github.event.number || github.event.inputs.PULL_REQUEST_NUMBER }} GITHUB_TOKEN: ${{ github.token }} getBranchRef: @@ -64,7 +66,7 @@ jobs: if: ${{ fromJSON(needs.validateActor.outputs.READY_TO_BUILD) }} runs-on: ubuntu-latest-xl env: - PULL_REQUEST_NUMBER: ${{ github.event.number || github.event.inputs.PULL_REQUEST_NUMBER }} + RUBYOPT: '-rostruct' steps: - name: Checkout uses: actions/checkout@v4 @@ -111,17 +113,19 @@ jobs: - name: Configure MapBox SDK run: ./scripts/setup-mapbox-sdk.sh ${{ secrets.MAPBOX_SDK_DOWNLOAD_TOKEN }} - - name: Run Fastlane beta test - id: runFastlaneBetaTest - run: bundle exec fastlane android build_internal + - name: Run AdHoc build + run: bundle exec fastlane android build_adhoc + env: + MYAPP_UPLOAD_STORE_PASSWORD: ${{ secrets.MYAPP_UPLOAD_STORE_PASSWORD }} + MYAPP_UPLOAD_KEY_PASSWORD: ${{ secrets.MYAPP_UPLOAD_KEY_PASSWORD }} + + - name: Upload AdHoc build to S3 + run: bundle exec fastlane android upload_s3 env: - RUBYOPT: '-rostruct' S3_ACCESS_KEY: ${{ secrets.AWS_ACCESS_KEY_ID }} S3_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} S3_BUCKET: ad-hoc-expensify-cash S3_REGION: us-east-1 - MYAPP_UPLOAD_STORE_PASSWORD: ${{ secrets.MYAPP_UPLOAD_STORE_PASSWORD }} - MYAPP_UPLOAD_KEY_PASSWORD: ${{ secrets.MYAPP_UPLOAD_KEY_PASSWORD }} - name: Upload Artifact uses: actions/upload-artifact@v4 @@ -134,7 +138,6 @@ jobs: needs: [validateActor, getBranchRef] if: ${{ fromJSON(needs.validateActor.outputs.READY_TO_BUILD) }} env: - PULL_REQUEST_NUMBER: ${{ github.event.number || github.event.inputs.PULL_REQUEST_NUMBER }} DEVELOPER_DIR: /Applications/Xcode_15.2.0.app/Contents/Developer runs-on: macos-13-xlarge steps: @@ -205,8 +208,11 @@ jobs: aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} aws-region: us-east-1 - - name: Run Fastlane - run: bundle exec fastlane ios build_internal + - name: Build AdHoc app + run: bundle exec fastlane ios build_adhoc + + - name: Upload AdHoc build to S3 + run: bundle exec fastlane ios upload_s3 env: S3_ACCESS_KEY: ${{ secrets.AWS_ACCESS_KEY_ID }} S3_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} @@ -223,8 +229,6 @@ jobs: name: Build and deploy Desktop for testing needs: [validateActor, getBranchRef] if: ${{ fromJSON(needs.validateActor.outputs.READY_TO_BUILD) }} - env: - PULL_REQUEST_NUMBER: ${{ github.event.number || github.event.inputs.PULL_REQUEST_NUMBER }} runs-on: macos-14-large steps: - name: Checkout @@ -268,8 +272,6 @@ jobs: name: Build and deploy Web needs: [validateActor, getBranchRef] if: ${{ fromJSON(needs.validateActor.outputs.READY_TO_BUILD) }} - env: - PULL_REQUEST_NUMBER: ${{ github.event.number || github.event.inputs.PULL_REQUEST_NUMBER }} runs-on: ubuntu-latest-xl steps: - name: Checkout @@ -304,8 +306,6 @@ jobs: name: Post a GitHub comment with app download links for testing needs: [validateActor, getBranchRef, android, iOS, desktop, web] if: ${{ always() }} - env: - PULL_REQUEST_NUMBER: ${{ github.event.number || github.event.inputs.PULL_REQUEST_NUMBER }} steps: - name: Checkout uses: actions/checkout@v4 diff --git a/android/app/build.gradle b/android/app/build.gradle index 3604cf25d1c..a1dacb600d3 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 1009003015 - versionName "9.0.30-15" + versionCode 1009003111 + versionName "9.0.31-11" // Supported language variants must be declared here to avoid from being removed during the compilation. // This also helps us to not include unnecessary language variants in the APK. resConfigs "en", "es" diff --git a/docs/articles/expensify-classic/bank-accounts-and-payments/Third-Party-Payments.md b/docs/articles/expensify-classic/bank-accounts-and-payments/Third-Party-Payments.md index cae289a0526..7d318fd3514 100644 --- a/docs/articles/expensify-classic/bank-accounts-and-payments/Third-Party-Payments.md +++ b/docs/articles/expensify-classic/bank-accounts-and-payments/Third-Party-Payments.md @@ -1,61 +1,29 @@ --- -title: Third Party Payments -description: A help article that covers Third Party Payment options including PayPal, Venmo, Wise, and Paylocity. +title: Third-Party Payments +description: Reimburse reports and pay bills using PayPal or Venmo. --- -# Expensify Third Party Payment Options - -Expensify offers convenient third party payment options that allow you to streamline the process of reimbursing expenses and managing your finances. With these options, you can pay your expenses and get reimbursed faster and more efficiently. In this guide, we'll walk you through the steps to set up and use Expensify's third party payment options. - -# Overview - -Expensify offers integration with various third party payment providers, making it easy to reimburse employees and manage your expenses seamlessly. Some of the key benefits of using third-party payment options in Expensify include: - +Expensify integrates with PayPal and Venmo, which can be used to reimburse employees or pay bills. Some of the key benefits of using a third-party payment provider are: - Faster Reimbursements: Expedite the reimbursement process and reduce the time it takes for employees to receive their funds. - Secure Transactions: Benefit from the security features and protocols provided by trusted payment providers. - Centralized Expense Management: Consolidate all your expenses and payments within Expensify for a more efficient financial workflow. -# Setting Up Third Party Payments - -To get started with third party payments in Expensify, follow these steps: - -1. **Log in to Expensify**: Access your Expensify account using your credentials. - -2. **Navigate to Settings**: Click on the "Settings" option in the top-right corner of the Expensify dashboard. - -3. **Select Payments**: In the Settings menu, find and click on the "Payments" or "Payment Methods" section. - -4. **Choose Third Party Payment Provider**: Select your preferred third party payment provider from the available options. Expensify may support providers such as PayPal, Venmo, Wise, and Paylocity. - -5. **Link Your Account**: Follow the prompts to link your third party payment account with Expensify. You may need to enter your account details and grant necessary permissions. +# Connect a third-party payment option -6. **Verify Your Account**: Confirm your linked account to ensure it's correctly integrated with Expensify. - -# Using Third Party Payments - -Once you've set up your third party payment option, you can start using it to reimburse expenses and manage payments: - -1. **Create an Expense Report**: Begin by creating an expense report in Expensify, adding all relevant expenses. - -2. **Submit for Approval**: After reviewing and verifying the expenses, submit the report for approval within Expensify. - -3. **Approval and Reimbursement**: Once the report is approved, the approved expenses can be reimbursed directly through your chosen third party payment provider. Expensify will automatically initiate the payment process. - -4. **Track Payment Status**: You can track the status of payments and view transaction details within your Expensify account. +To connect a third-party payment platform to Expensify: +1. Log into your Expensify web account +2. Head to **Settings > Account > Payments > Alternative Payment Accounts** +3. Choose PayPal or Venmo + - **PayPal**: Enter your username in the `paypal.me/` field + - **Venmo**: Receive invoices via Venmo by adding your mobile phone number as a Secondary Login {% include faq-begin.md %} -## Q: Are there any fees associated with using third party payment options in Expensify? - -A: The fees associated with third party payments may vary depending on the payment provider you choose. Be sure to review the terms and conditions of your chosen provider for details on any applicable fees. - -## Q: Can I use multiple third party payment providers with Expensify? - -A: Expensify allows you to link multiple payment providers if needed. You can select the most suitable payment method for each expense report. +## Can I use multiple third-party payment providers with Expensify? -## Q: Is there a limit on the amount I can reimburse using third party payments? +Yes, you can link both your Venmo and PayPal accounts to Expensify if you'd like. -A: The reimbursement limit may depend on the policies and settings configured within your Expensify account and the limits imposed by your chosen payment provider. +## Is there a limit on the amount I can reimburse using third party payments? -With Expensify's third party payment options, you can simplify your expense management and reimbursement processes. By following the steps outlined in this guide, you can set up and use third party payments efficiently. +The payment limit is dependent on the settings configured within your Expensify account as well as the limits imposed by the third-party payment provider. {% include faq-end.md %} diff --git a/docs/articles/expensify-classic/bank-accounts-and-payments/payments/Get-reimbursed-faster-as-a-non-US-employee.md b/docs/articles/expensify-classic/bank-accounts-and-payments/payments/Get-reimbursed-faster-as-a-non-US-employee.md new file mode 100644 index 00000000000..30dea99bbfd --- /dev/null +++ b/docs/articles/expensify-classic/bank-accounts-and-payments/payments/Get-reimbursed-faster-as-a-non-US-employee.md @@ -0,0 +1,24 @@ +--- +title: Get Reimbursed Faster as a Non-US Employee +description: How to use Wise to get paid faster +--- + +If you are an overseas employee who works for a US-based company, you can use Wise to be reimbursed for expenses just as quickly as your US-based colleagues. Wise (formerly TransferWise) is an FCA-regulated global money transfer service. + +Here’s how it works: + +1. When you sign up for a Wise account, you are provided with a USD checking account number and a routing number to use as your Expensify bank account. +2. Once you receive a reimbursement, it will be deposited directly into your Wise account. +3. You can then convert your funds into 40+ different currencies and withdraw them to your local bank account. If you live in the UK or EU, you can also get a debit card to spend money directly from your Wise account. + +## Set up reimbursements through Wise + +1. Check with your company to see if you can submit your expenses in USD. +2. Sign up for a Wise Borderless Account and get verified (verification can take up to 3 days). +3. In Expensify, [add a deposit-only bank account](https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/bank-accounts/Connect-Personal-US-Bank-Account) with your Wise USD account and ACH routing numbers (NOT the wire transfer routing number). + +{% include info.html %} +Do not include spaces in the Wise account number, which should be 16 digits. +{% include end-info.html %} + +If your expenses are not in USD, Expensify will automatically convert them to USD when they are added to your expense report. Once you submit your expenses to your company’s USD workspace and they are approved, you will receive the reimbursement for the approved report total in USD in your Wise account. diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 2560e48728c..66c5000a6ea 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -15,9 +15,62 @@ require 'ostruct' skip_docs opt_out_usage +KEY_GRADLE_APK_PATH = "gradleAPKOutputPath" +KEY_IPA_PATH = "ipaPath" +KEY_DSYM_PATH = "dsymPath" + +# Export environment variables in the parent shell. +# In a GitHub Actions environment, it will save the environment variables in the GITHUB_ENV file. +# In any other environment, it will save them to the current shell environment using the `export` command. +def exportEnvVars(env_vars) + github_env_path = ENV['GITHUB_ENV'] + if github_env_path && File.exist?(github_env_path) + puts "Saving environment variables in GITHUB_ENV..." + File.open(github_env_path, "a") do |file| + env_vars.each do |key, value| + puts "#{key}=#{value}" + file.puts "#{key}=#{value}" + end + end + else + puts "Saving environment variables in parent shell..." + env_vars.each do |key, value| + puts "#{key}=#{value}" + command = "export #{key}=#{value}" + system(command) + end + end +end + +def setGradleOutputsInEnv() + puts "Saving Android build outputs in env..." + exportEnvVars({ + KEY_GRADLE_APK_PATH => lane_context[SharedValues::GRADLE_APK_OUTPUT_PATH], + }) +end + +def setIOSBuildOutputsInEnv() + puts "Saving iOS build outputs in env..." + exportEnvVars({ + KEY_IPA_PATH => lane_context[SharedValues::IPA_OUTPUT_PATH], + KEY_DSYM_PATH => lane_context[SharedValues::DSYM_OUTPUT_PATH], + }) +end + platform :android do - desc "Generate a new local APK for e2e testing" + desc "Generate a new local APK" + lane :build do + ENV["ENVFILE"]=".env.production" + gradle( + project_dir: './android', + task: 'assemble', + flavor: 'Production', + build_type: 'Release', + ) + setGradleOutputsInEnv() + end + desc "Generate a new local APK for e2e testing" lane :build_e2e do ENV["ENVFILE"]="tests/e2e/.env.e2e" ENV["ENTRY_FILE"]="src/libs/E2E/reactNativeLaunchingTest.ts" @@ -29,6 +82,7 @@ platform :android do flavor: 'e2e', build_type: 'Release', ) + setGradleOutputsInEnv() end lane :build_e2edelta do @@ -42,68 +96,50 @@ platform :android do flavor: 'e2edelta', build_type: 'Release', ) + setGradleOutputsInEnv() end - desc "Generate a new local APK" - lane :build do - ENV["ENVFILE"]=".env.production" - + desc "Build AdHoc testing build" + lane :build_adhoc do + ENV["ENVFILE"]=".env.adhoc" gradle( project_dir: './android', task: 'assemble', - flavor: 'Production', + flavor: 'Adhoc', build_type: 'Release', ) + setGradleOutputsInEnv() end - desc "Build app for testing" - lane :build_internal do - ENV["ENVFILE"]=".env.adhoc" - - gradle( - project_dir: './android', - task: 'assemble', - flavor: 'Adhoc', - build_type: 'Release', - ) - + desc "Upload build to S3" + lane :upload_s3 do + puts "APK path: #{ENV[KEY_GRADLE_APK_PATH]}" aws_s3( access_key: ENV['S3_ACCESS_KEY'], secret_access_key: ENV['S3_SECRET_ACCESS_KEY'], bucket: ENV['S3_BUCKET'], region: ENV['S3_REGION'], - - apk: lane_context[SharedValues::GRADLE_APK_OUTPUT_PATH], + apk: ENV[KEY_GRADLE_APK_PATH], app_directory: "android/#{ENV['PULL_REQUEST_NUMBER']}", ) - sh("echo '{\"apk_path\": \"#{lane_context[SharedValues::S3_APK_OUTPUT_PATH]}\",\"html_path\": \"#{lane_context[SharedValues::S3_HTML_OUTPUT_PATH]}\"}' > ../android_paths.json") end - desc "Build and upload app to Google Play" - lane :beta do - ENV["ENVFILE"]=".env.production" + desc "Upload app to Google Play for internal testing" + lane :upload_google_play_internal do # Google is very unreliable, so we retry a few times ENV["SUPPLY_UPLOAD_MAX_RETRIES"]="5" - - gradle( - project_dir: './android', - task: 'bundle', - flavor: 'Production', - build_type: 'Release', - ) - upload_to_play_store( - package_name: "com.expensify.chat", - json_key: './android/app/android-fastlane-json-key.json', - aab: './android/app/build/outputs/bundle/productionRelease/app-production-release.aab', - track: 'internal', - rollout: '1.0' + package_name: "com.expensify.chat", + json_key: './android/app/android-fastlane-json-key.json', + aab: './android/app/build/outputs/bundle/productionRelease/app-production-release.aab', + track: 'internal', + rollout: '1.0' ) end desc "Deploy app to Google Play production" - lane :production do + lane :upload_google_play_production do # Google is very unreliable, so we retry a few times ENV["SUPPLY_UPLOAD_MAX_RETRIES"]="5" google_play_track_version_codes( @@ -111,7 +147,6 @@ platform :android do json_key: './android/app/android-fastlane-json-key.json', track: 'internal' ) - upload_to_play_store( package_name: "com.expensify.chat", json_key: './android/app/android-fastlane-json-key.json', @@ -129,118 +164,114 @@ platform :android do end end +def setupIOSSigningCertificate() + require 'securerandom' + keychain_password = SecureRandom.uuid + + create_keychain( + name: "ios-build.keychain", + password: keychain_password, + default_keychain: "true", + unlock: "true", + timeout: "3600", + add_to_search_list: "true" + ) + + import_certificate( + certificate_path: "./ios/Certificates.p12", + keychain_name: "ios-build.keychain", + keychain_password: keychain_password + ) +end + platform :ios do - desc "Generate a local iOS production build" + desc "Build an iOS production build" lane :build do ENV["ENVFILE"]=".env.production" + setupIOSSigningCertificate() + + install_provisioning_profile( + path: "./ios/NewApp_AppStore.mobileprovision" + ) + + install_provisioning_profile( + path: "./ios/NewApp_AppStore_Notification_Service.mobileprovision" + ) + + build_app( + workspace: "./ios/NewExpensify.xcworkspace", + scheme: "New Expensify", + output_name: "New Expensify.ipa", + export_options: { + provisioningProfiles: { + "com.chat.expensify.chat" => "(NewApp) AppStore", + "com.chat.expensify.chat.NotificationServiceExtension" => "(NewApp) AppStore: Notification Service", + }, + manageAppVersionAndBuildNumber: false + } + ) + + setIOSBuildOutputsInEnv() + end + + desc "Build an unsigned iOS production build" + lane :build_unsigned do + ENV["ENVFILE"]=".env.production" build_app( workspace: "./ios/NewExpensify.xcworkspace", scheme: "New Expensify" ) + setIOSBuildOutputsInEnv() end - desc "Build app for testing" - lane :build_internal do - require 'securerandom' + desc "Build AdHoc app for testing" + lane :build_adhoc do ENV["ENVFILE"]=".env.adhoc" - keychain_password = SecureRandom.uuid - - create_keychain( - name: "ios-build.keychain", - password: keychain_password, - default_keychain: "true", - unlock: "true", - timeout: "3600", - add_to_search_list: "true" - ) - - import_certificate( - certificate_path: "./ios/Certificates.p12", - keychain_name: "ios-build.keychain", - keychain_password: keychain_password - ) + setupIOSSigningCertificate() install_provisioning_profile( - path: "./ios/NewApp_AdHoc.mobileprovision" + path: "./ios/NewApp_AdHoc.mobileprovision" ) install_provisioning_profile( - path: "./ios/NewApp_AdHoc_Notification_Service.mobileprovision" + path: "./ios/NewApp_AdHoc_Notification_Service.mobileprovision" ) build_app( - workspace: "./ios/NewExpensify.xcworkspace", - skip_profile_detection: true, - scheme: "New Expensify AdHoc", - export_method: "ad-hoc", - export_options: { - method: "ad-hoc", - provisioningProfiles: { - "com.expensify.chat.adhoc" => "(NewApp) AdHoc", - "com.expensify.chat.adhoc.NotificationServiceExtension" => "(NewApp) AdHoc: Notification Service", - }, - manageAppVersionAndBuildNumber: false - } + workspace: "./ios/NewExpensify.xcworkspace", + skip_profile_detection: true, + scheme: "New Expensify AdHoc", + export_method: "ad-hoc", + export_options: { + method: "ad-hoc", + provisioningProfiles: { + "com.expensify.chat.adhoc" => "(NewApp) AdHoc", + "com.expensify.chat.adhoc.NotificationServiceExtension" => "(NewApp) AdHoc: Notification Service", + }, + manageAppVersionAndBuildNumber: false + } ) + setIOSBuildOutputsInEnv() + end + desc "Upload app to S3" + lane :upload_s3 do + puts "IPA path: #{ENV[KEY_IPA_PATH]}" aws_s3( access_key: ENV['S3_ACCESS_KEY'], secret_access_key: ENV['S3_SECRET_ACCESS_KEY'], bucket: ENV['S3_BUCKET'], region: ENV['S3_REGION'], - - ipa: lane_context[SharedValues::IPA_OUTPUT_PATH], + ipa: ENV[KEY_IPA_PATH], app_directory: "ios/#{ENV['PULL_REQUEST_NUMBER']}", ) - sh("echo '{\"ipa_path\": \"#{lane_context[SharedValues::S3_IPA_OUTPUT_PATH]}\",\"html_path\": \"#{lane_context[SharedValues::S3_HTML_OUTPUT_PATH]}\"}' > ../ios_paths.json") end - desc "Build and upload app to TestFlight" - lane :beta do - require 'securerandom' - ENV["ENVFILE"]=".env.production" - - keychain_password = SecureRandom.uuid - - create_keychain( - name: "ios-build.keychain", - password: keychain_password, - default_keychain: "true", - unlock: "true", - timeout: "3600", - add_to_search_list: "true" - ) - - import_certificate( - certificate_path: "./ios/Certificates.p12", - keychain_name: "ios-build.keychain", - keychain_password: keychain_password - ) - - install_provisioning_profile( - path: "./ios/NewApp_AppStore.mobileprovision" - ) - - install_provisioning_profile( - path: "./ios/NewApp_AppStore_Notification_Service.mobileprovision" - ) - - build_app( - workspace: "./ios/NewExpensify.xcworkspace", - scheme: "New Expensify", - output_name: "New Expensify.ipa", - export_options: { - provisioningProfiles: { - "com.chat.expensify.chat" => "(NewApp) AppStore", - "com.chat.expensify.chat.NotificationServiceExtension" => "(NewApp) AppStore: Notification Service", - }, - manageAppVersionAndBuildNumber: false - } - ) - + desc "Upload app to TestFlight" + lane :upload_testflight do upload_to_testflight( api_key_path: "./ios/ios-fastlane-json-key.json", distribute_external: true, @@ -249,30 +280,31 @@ platform :ios do groups: ["Beta"], demo_account_required: true, beta_app_review_info: { - contact_email: ENV["APPLE_CONTACT_EMAIL"], - contact_first_name: "Andrew", - contact_last_name: "Gable", - contact_phone: ENV["APPLE_CONTACT_PHONE"], - demo_account_name: ENV["APPLE_DEMO_EMAIL"], - demo_account_password: ENV["APPLE_DEMO_PASSWORD"], - notes: "1. In the Expensify app, enter the email 'appletest.expensify@proton.me'. This will trigger a sign-in link to be sent to 'appletest.expensify@proton.me' - 2. Navigate to https://account.proton.me/login, log into Proton Mail using 'appletest.expensify@proton.me' as email and the password associated with 'appletest.expensify@proton.me', provided above - 3. Once logged into Proton Mail, navigate to your inbox and locate the email triggered in step 1. The email subject should be 'Your magic sign-in link for Expensify' - 4. Open the email and copy the 6-digit sign-in code provided within - 5. Return to the Expensify app and enter the copied 6-digit code in the designated login field" + contact_email: ENV["APPLE_CONTACT_EMAIL"], + contact_first_name: "Andrew", + contact_last_name: "Gable", + contact_phone: ENV["APPLE_CONTACT_PHONE"], + demo_account_name: ENV["APPLE_DEMO_EMAIL"], + demo_account_password: ENV["APPLE_DEMO_PASSWORD"], + notes: "1. In the Expensify app, enter the email 'appletest.expensify@proton.me'. This will trigger a sign-in link to be sent to 'appletest.expensify@proton.me' + 2. Navigate to https://account.proton.me/login, log into Proton Mail using 'appletest.expensify@proton.me' as email and the password associated with 'appletest.expensify@proton.me', provided above + 3. Once logged into Proton Mail, navigate to your inbox and locate the email triggered in step 1. The email subject should be 'Your magic sign-in link for Expensify' + 4. Open the email and copy the 6-digit sign-in code provided within + 5. Return to the Expensify app and enter the copied 6-digit code in the designated login field" } ) + puts "dsym path: #{ENV[KEY_DSYM_PATH]}" upload_symbols_to_crashlytics( app_id: "1:921154746561:ios:216bd10ccc947659027c40", - dsym_path: lane_context[SharedValues::DSYM_OUTPUT_PATH], + dsym_path: ENV[KEY_DSYM_PATH], gsp_path: "./ios/GoogleService-Info.plist", binary_path: "./ios/Pods/FirebaseCrashlytics/upload-symbols" ) end - desc "Move app to App Store Review" - lane :production do + desc "Submit app to App Store Review" + lane :submit_for_review do deliver( api_key_path: "./ios/ios-fastlane-json-key.json", @@ -309,7 +341,6 @@ platform :ios do # Precheck cannot check for in app purchases with the API key we use precheck_include_in_app_purchases: false, submission_information: { - # We currently do not use idfa: https://developer.apple.com/app-store/user-privacy-and-data-use/ add_id_info_uses_idfa: false, @@ -334,6 +365,5 @@ platform :ios do 'en-US' => "Improvements and bug fixes" } ) - end end diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index 27d35cd77d3..509d0587d65 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -19,7 +19,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 9.0.30 + 9.0.31 CFBundleSignature ???? CFBundleURLTypes @@ -40,7 +40,7 @@ CFBundleVersion - 9.0.30.15 + 9.0.31.11 FullStory OrgId diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index 8e299efa41e..ff9a1856757 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -15,10 +15,10 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 9.0.30 + 9.0.31 CFBundleSignature ???? CFBundleVersion - 9.0.30.15 + 9.0.31.11 diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index a037eddf9eb..ac3a89fb955 100644 --- a/ios/NotificationServiceExtension/Info.plist +++ b/ios/NotificationServiceExtension/Info.plist @@ -11,9 +11,9 @@ CFBundleName $(PRODUCT_NAME) CFBundleShortVersionString - 9.0.30 + 9.0.31 CFBundleVersion - 9.0.30.15 + 9.0.31.11 NSExtension NSExtensionPointIdentifier diff --git a/package-lock.json b/package-lock.json index d04006e0837..7c9248f5e05 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "9.0.30-15", + "version": "9.0.31-11", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "9.0.30-15", + "version": "9.0.31-11", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -43,7 +43,7 @@ "@react-navigation/native": "6.1.12", "@react-navigation/stack": "6.3.29", "@react-ng/bounds-observer": "^0.2.1", - "@rnmapbox/maps": "10.1.26", + "@rnmapbox/maps": "10.1.30", "@shopify/flash-list": "1.7.1", "@types/mime-db": "^1.43.5", "@ua/react-native-airship": "19.2.1", @@ -56,7 +56,7 @@ "date-fns-tz": "^2.0.0", "dom-serializer": "^0.2.2", "domhandler": "^4.3.0", - "expensify-common": "2.0.83", + "expensify-common": "2.0.84", "expo": "51.0.17", "expo-av": "14.0.6", "expo-image": "1.12.12", @@ -10205,8 +10205,9 @@ } }, "node_modules/@rnmapbox/maps": { - "version": "10.1.26", - "license": "MIT", + "version": "10.1.30", + "resolved": "https://registry.npmjs.org/@rnmapbox/maps/-/maps-10.1.30.tgz", + "integrity": "sha512-3yl043+mpBldIHxTMMBU6Rdka6IjSww3kaIngltsUBTtnQI9NE1Yv3msC1X10E5bcfLHrhLxkiMSRhckCKBkPA==", "dependencies": { "@turf/along": "6.5.0", "@turf/distance": "6.5.0", @@ -26272,9 +26273,9 @@ } }, "node_modules/expensify-common": { - "version": "2.0.83", - "resolved": "https://registry.npmjs.org/expensify-common/-/expensify-common-2.0.83.tgz", - "integrity": "sha512-7CHVxV5yEJ43GGKF0UXiLKaSdfKaSHE4YC2+30gKxuWbs5XrOLOK3TcCzk54uBfbmPjmx6VrADbR9uzS4H0A0g==", + "version": "2.0.84", + "resolved": "https://registry.npmjs.org/expensify-common/-/expensify-common-2.0.84.tgz", + "integrity": "sha512-VistjMexRz/1u1IqjIZwGRE7aS6QOat7420Dualn+NaqMHGkfeeB4uUR3RQhCtlDbcwFBKTryIGgSrrC0N1YpA==", "dependencies": { "awesome-phonenumber": "^5.4.0", "classnames": "2.5.0", diff --git a/package.json b/package.json index 7da6a74dcbb..31777922854 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "9.0.30-15", + "version": "9.0.31-11", "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.", @@ -30,7 +30,7 @@ "createDocsRoutes": "ts-node .github/scripts/createDocsRoutes.ts", "detectRedirectCycle": "ts-node .github/scripts/detectRedirectCycle.ts", "desktop-build-adhoc": "scripts/build-desktop.sh adhoc", - "ios-build": "fastlane ios build", + "ios-build": "fastlane ios build_unsigned", "android-build": "fastlane android build", "android-build-e2e": "bundle exec fastlane android build_e2e", "android-build-e2edelta": "bundle exec fastlane android build_e2edelta", @@ -100,7 +100,7 @@ "@react-navigation/native": "6.1.12", "@react-navigation/stack": "6.3.29", "@react-ng/bounds-observer": "^0.2.1", - "@rnmapbox/maps": "10.1.26", + "@rnmapbox/maps": "10.1.30", "@shopify/flash-list": "1.7.1", "@types/mime-db": "^1.43.5", "@ua/react-native-airship": "19.2.1", @@ -113,7 +113,7 @@ "date-fns-tz": "^2.0.0", "dom-serializer": "^0.2.2", "domhandler": "^4.3.0", - "expensify-common": "2.0.83", + "expensify-common": "2.0.84", "expo": "51.0.17", "expo-av": "14.0.6", "expo-image": "1.12.12", diff --git a/patches/@rnmapbox+maps+10.1.26+001+rn-75-fixes.patch b/patches/@rnmapbox+maps+10.1.26+001+rn-75-fixes.patch deleted file mode 100644 index c8e3719e80d..00000000000 --- a/patches/@rnmapbox+maps+10.1.26+001+rn-75-fixes.patch +++ /dev/null @@ -1,188 +0,0 @@ -diff --git a/node_modules/@rnmapbox/maps/android/src/main/java/com/rnmapbox/rnmbx/components/camera/RNMBXCamera.kt b/node_modules/@rnmapbox/maps/android/src/main/java/com/rnmapbox/rnmbx/components/camera/RNMBXCamera.kt -index bf149f9..2d3441b 100644 ---- a/node_modules/@rnmapbox/maps/android/src/main/java/com/rnmapbox/rnmbx/components/camera/RNMBXCamera.kt -+++ b/node_modules/@rnmapbox/maps/android/src/main/java/com/rnmapbox/rnmbx/components/camera/RNMBXCamera.kt -@@ -190,7 +190,7 @@ class RNMBXCamera(private val mContext: Context, private val mManager: RNMBXCame - - private fun setInitialCamera() { - mDefaultStop?.let { -- val mapView = mMapView!! -+ val mapView = mMapView ?: return - val map = mapView.getMapboxMap() - - it.setDuration(0) -diff --git a/node_modules/@rnmapbox/maps/android/src/main/java/com/rnmapbox/rnmbx/components/images/RNMBXImagesManager.kt b/node_modules/@rnmapbox/maps/android/src/main/java/com/rnmapbox/rnmbx/components/images/RNMBXImagesManager.kt -index 67c8656..248011f 100644 ---- a/node_modules/@rnmapbox/maps/android/src/main/java/com/rnmapbox/rnmbx/components/images/RNMBXImagesManager.kt -+++ b/node_modules/@rnmapbox/maps/android/src/main/java/com/rnmapbox/rnmbx/components/images/RNMBXImagesManager.kt -@@ -210,7 +210,7 @@ class RNMBXImagesManager(private val mContext: ReactApplicationContext) : - - // region RNMBXImage children - -- override fun addView(parent: RNMBXImages?, childView: View?, childPosition: Int) { -+ override fun addView(parent: RNMBXImages, childView: View, childPosition: Int) { - if (parent == null || childView == null) { - Logger.e("RNMBXImages", "addView: parent or childView is null") - return -@@ -225,7 +225,7 @@ class RNMBXImagesManager(private val mContext: ReactApplicationContext) : - childView.nativeImageUpdater = parent - } - -- override fun removeView(parent: RNMBXImages?, view: View?) { -+ override fun removeView(parent: RNMBXImages, view: View) { - if (parent == null || view == null) { - Logger.e("RNMBXImages", "removeView: parent or view is null") - return -@@ -234,7 +234,7 @@ class RNMBXImagesManager(private val mContext: ReactApplicationContext) : - parent.mImageViews.remove(view) - } - -- override fun removeAllViews(parent: RNMBXImages?) { -+ override fun removeAllViews(parent: RNMBXImages) { - if (parent == null) { - Logger.e("RNMBXImages", "removeAllViews parent is null") - return -diff --git a/node_modules/@rnmapbox/maps/android/src/main/java/com/rnmapbox/rnmbx/components/mapview/NativeMapViewModule.kt b/node_modules/@rnmapbox/maps/android/src/main/java/com/rnmapbox/rnmbx/components/mapview/NativeMapViewModule.kt -index ef529ef..4115802 100644 ---- a/node_modules/@rnmapbox/maps/android/src/main/java/com/rnmapbox/rnmbx/components/mapview/NativeMapViewModule.kt -+++ b/node_modules/@rnmapbox/maps/android/src/main/java/com/rnmapbox/rnmbx/components/mapview/NativeMapViewModule.kt -@@ -152,14 +152,6 @@ class NativeMapViewModule(context: ReactApplicationContext, val viewTagResolver: - } - } - -- public fun setHandledMapChangedEvents( -- viewRef: Double?, -- events: ReadableArray, -- promise: Promise -- ) { -- setHandledMapChangedEvents(viewRef?.toInt(), events, promise) -- } -- - override fun clearData(viewRef: ViewRefTag?, promise: Promise) { - withMapViewOnUIThread(viewRef, promise) { - it.clearData(createCommandResponse(promise)) -diff --git a/node_modules/@rnmapbox/maps/android/src/main/java/com/rnmapbox/rnmbx/components/mapview/RNMBXMapViewManager.kt b/node_modules/@rnmapbox/maps/android/src/main/java/com/rnmapbox/rnmbx/components/mapview/RNMBXMapViewManager.kt -index 98febe7..8601286 100644 ---- a/node_modules/@rnmapbox/maps/android/src/main/java/com/rnmapbox/rnmbx/components/mapview/RNMBXMapViewManager.kt -+++ b/node_modules/@rnmapbox/maps/android/src/main/java/com/rnmapbox/rnmbx/components/mapview/RNMBXMapViewManager.kt -@@ -86,19 +86,19 @@ open class RNMBXMapViewManager(context: ReactApplicationContext, val viewTagReso - } - } - -- override fun addView(mapView: RNMBXMapView?, childView: View?, childPosition: Int) { -+ override fun addView(mapView: RNMBXMapView, childView: View, childPosition: Int) { - mapView!!.addFeature(childView, childPosition) - } - -- override fun getChildCount(mapView: RNMBXMapView?): Int { -+ override fun getChildCount(mapView: RNMBXMapView): Int { - return mapView!!.featureCount - } - -- override fun getChildAt(mapView: RNMBXMapView?, index: Int): View? { -+ override fun getChildAt(mapView: RNMBXMapView, index: Int): View? { - return mapView!!.getFeatureAt(index) - } - -- override fun removeViewAt(mapView: RNMBXMapView?, index: Int) { -+ override fun removeViewAt(mapView: RNMBXMapView, index: Int) { - mapView!!.removeFeatureAt(index) - } - -diff --git a/node_modules/@rnmapbox/maps/android/src/main/java/com/rnmapbox/rnmbx/components/styles/sources/RNMBXImageSource.kt b/node_modules/@rnmapbox/maps/android/src/main/java/com/rnmapbox/rnmbx/components/styles/sources/RNMBXImageSource.kt -index be22072..602ca6d 100644 ---- a/node_modules/@rnmapbox/maps/android/src/main/java/com/rnmapbox/rnmbx/components/styles/sources/RNMBXImageSource.kt -+++ b/node_modules/@rnmapbox/maps/android/src/main/java/com/rnmapbox/rnmbx/components/styles/sources/RNMBXImageSource.kt -@@ -37,7 +37,7 @@ class RNMBXImageSource(context: Context?) : RNMBXSource(context) { - val uri = Uri.parse(url) - if (uri.scheme == null) { - mResourceId = -- ResourceDrawableIdHelper.getInstance().getResourceDrawableId(this.context, url) -+ ResourceDrawableIdHelper.instance.getResourceDrawableId(this.context, url) - if (mSource != null) { - throw RuntimeException("ImageSource Resource id not supported in v10") - } -diff --git a/node_modules/@rnmapbox/maps/android/src/main/java/com/rnmapbox/rnmbx/components/styles/sources/RNMBXRasterDemSourceManager.kt b/node_modules/@rnmapbox/maps/android/src/main/java/com/rnmapbox/rnmbx/components/styles/sources/RNMBXRasterDemSourceManager.kt -index c843d11..70a2c47 100644 ---- a/node_modules/@rnmapbox/maps/android/src/main/java/com/rnmapbox/rnmbx/components/styles/sources/RNMBXRasterDemSourceManager.kt -+++ b/node_modules/@rnmapbox/maps/android/src/main/java/com/rnmapbox/rnmbx/components/styles/sources/RNMBXRasterDemSourceManager.kt -@@ -11,10 +11,10 @@ import com.rnmapbox.rnmbx.utils.Logger - // import com.rnmapbox.rnmbx.components.annotation.RNMBXCallout; - // import com.rnmapbox.rnmbx.utils.ResourceUtils; - class RNMBXRasterDemSourceManager(private val mContext: ReactApplicationContext) : -- RNMBXTileSourceManager( -+ RNMBXTileSourceManager( - mContext - ), RNMBXRasterDemSourceManagerInterface { -- override fun customEvents(): Map? { -+ override fun customEvents(): Map { - return MapBuilder.builder() - .build() - } -diff --git a/node_modules/@rnmapbox/maps/android/src/main/java/com/rnmapbox/rnmbx/components/styles/sources/RNMBXRasterSourceManager.kt b/node_modules/@rnmapbox/maps/android/src/main/java/com/rnmapbox/rnmbx/components/styles/sources/RNMBXRasterSourceManager.kt -index 5bebc1b..893d757 100644 ---- a/node_modules/@rnmapbox/maps/android/src/main/java/com/rnmapbox/rnmbx/components/styles/sources/RNMBXRasterSourceManager.kt -+++ b/node_modules/@rnmapbox/maps/android/src/main/java/com/rnmapbox/rnmbx/components/styles/sources/RNMBXRasterSourceManager.kt -@@ -8,7 +8,7 @@ import com.facebook.react.viewmanagers.RNMBXRasterSourceManagerInterface - import javax.annotation.Nonnull - - class RNMBXRasterSourceManager(reactApplicationContext: ReactApplicationContext) : -- RNMBXTileSourceManager(reactApplicationContext), -+ RNMBXTileSourceManager(reactApplicationContext), - RNMBXRasterSourceManagerInterface { - @Nonnull - override fun getName(): String { -diff --git a/node_modules/@rnmapbox/maps/android/src/main/java/com/rnmapbox/rnmbx/components/styles/sources/RNMBXShapeSourceModule.kt b/node_modules/@rnmapbox/maps/android/src/main/java/com/rnmapbox/rnmbx/components/styles/sources/RNMBXShapeSourceModule.kt -index 6398497..03c1829 100644 ---- a/node_modules/@rnmapbox/maps/android/src/main/java/com/rnmapbox/rnmbx/components/styles/sources/RNMBXShapeSourceModule.kt -+++ b/node_modules/@rnmapbox/maps/android/src/main/java/com/rnmapbox/rnmbx/components/styles/sources/RNMBXShapeSourceModule.kt -@@ -44,8 +44,8 @@ class RNMBXShapeSourceModule(reactContext: ReactApplicationContext?, private val - override fun getClusterLeaves( - viewRef: ViewRefTag?, - featureJSON: String, -- number: Int, -- offset: Int, -+ number: Double, -+ offset: Double, - promise: Promise - ) { - withShapeSourceOnUIThread(viewRef, promise) { -diff --git a/node_modules/@rnmapbox/maps/android/src/main/java/com/rnmapbox/rnmbx/components/styles/sources/RNMBXTileSourceManager.kt b/node_modules/@rnmapbox/maps/android/src/main/java/com/rnmapbox/rnmbx/components/styles/sources/RNMBXTileSourceManager.kt -index 767d27b..5ebe505 100644 ---- a/node_modules/@rnmapbox/maps/android/src/main/java/com/rnmapbox/rnmbx/components/styles/sources/RNMBXTileSourceManager.kt -+++ b/node_modules/@rnmapbox/maps/android/src/main/java/com/rnmapbox/rnmbx/components/styles/sources/RNMBXTileSourceManager.kt -@@ -7,7 +7,7 @@ import com.facebook.react.bridge.ReadableType - import com.facebook.react.uimanager.annotations.ReactProp - import com.rnmapbox.rnmbx.components.AbstractEventEmitter - --abstract class RNMBXTileSourceManager?> internal constructor( -+abstract class RNMBXTileSourceManager> internal constructor( - reactApplicationContext: ReactApplicationContext - ) : AbstractEventEmitter(reactApplicationContext) { - override fun getChildAt(source: T, childPosition: Int): View { -diff --git a/node_modules/@rnmapbox/maps/android/src/main/java/com/rnmapbox/rnmbx/components/styles/sources/RNMBXVectorSourceManager.kt b/node_modules/@rnmapbox/maps/android/src/main/java/com/rnmapbox/rnmbx/components/styles/sources/RNMBXVectorSourceManager.kt -index 63b1cfb..b0d3e88 100644 ---- a/node_modules/@rnmapbox/maps/android/src/main/java/com/rnmapbox/rnmbx/components/styles/sources/RNMBXVectorSourceManager.kt -+++ b/node_modules/@rnmapbox/maps/android/src/main/java/com/rnmapbox/rnmbx/components/styles/sources/RNMBXVectorSourceManager.kt -@@ -11,7 +11,7 @@ import com.rnmapbox.rnmbx.events.constants.eventMapOf - import javax.annotation.Nonnull - - class RNMBXVectorSourceManager(reactApplicationContext: ReactApplicationContext) : -- RNMBXTileSourceManager(reactApplicationContext), -+ RNMBXTileSourceManager(reactApplicationContext), - RNMBXVectorSourceManagerInterface { - @Nonnull - override fun getName(): String { -diff --git a/node_modules/@rnmapbox/maps/android/src/main/java/com/rnmapbox/rnmbx/utils/ViewTagResolver.kt b/node_modules/@rnmapbox/maps/android/src/main/java/com/rnmapbox/rnmbx/utils/ViewTagResolver.kt -index 07bac4d..f45cc25 100644 ---- a/node_modules/@rnmapbox/maps/android/src/main/java/com/rnmapbox/rnmbx/utils/ViewTagResolver.kt -+++ b/node_modules/@rnmapbox/maps/android/src/main/java/com/rnmapbox/rnmbx/utils/ViewTagResolver.kt -@@ -16,7 +16,7 @@ data class ViewTagWaiter( - - const val LOG_TAG = "ViewTagResolver" - --typealias ViewRefTag = Int -+typealias ViewRefTag = Double - // see https://github.com/rnmapbox/maps/pull/3074 - open class ViewTagResolver(val context: ReactApplicationContext) { - private val createdViews: HashSet = hashSetOf() diff --git a/patches/date-fns-tz+2.0.0.patch b/patches/date-fns-tz+2.0.0.patch new file mode 100644 index 00000000000..aa88f1443a7 --- /dev/null +++ b/patches/date-fns-tz+2.0.0.patch @@ -0,0 +1,84 @@ +diff --git a/node_modules/date-fns-tz/_lib/tzTokenizeDate/index.js b/node_modules/date-fns-tz/_lib/tzTokenizeDate/index.js +index 9222a61..8540224 100644 +--- a/node_modules/date-fns-tz/_lib/tzTokenizeDate/index.js ++++ b/node_modules/date-fns-tz/_lib/tzTokenizeDate/index.js +@@ -59,20 +59,23 @@ function hackyOffset(dtf, date) { + + var dtfCache = {}; + ++// New browsers use `hourCycle`, IE and Chrome <73 does not support it and uses `hour12` ++const testDateFormatted = new Intl.DateTimeFormat('en-US', { ++ hourCycle: 'h23', ++ timeZone: 'America/New_York', ++ year: 'numeric', ++ month: '2-digit', ++ day: '2-digit', ++ hour: '2-digit', ++ minute: '2-digit', ++ second: '2-digit', ++}).format(new Date('2014-06-25T04:00:00.123Z')) ++const hourCycleSupported = ++ testDateFormatted === '06/25/2014, 00:00:00' || ++ testDateFormatted === 'β€Ž06β€Ž/β€Ž25β€Ž/β€Ž2014β€Ž β€Ž00β€Ž:β€Ž00β€Ž:β€Ž00' ++ + function getDateTimeFormat(timeZone) { + if (!dtfCache[timeZone]) { +- // New browsers use `hourCycle`, IE and Chrome <73 does not support it and uses `hour12` +- var testDateFormatted = new Intl.DateTimeFormat('en-US', { +- hour12: false, +- timeZone: 'America/New_York', +- year: 'numeric', +- month: 'numeric', +- day: '2-digit', +- hour: '2-digit', +- minute: '2-digit', +- second: '2-digit' +- }).format(new Date('2014-06-25T04:00:00.123Z')); +- var hourCycleSupported = testDateFormatted === '06/25/2014, 00:00:00' || testDateFormatted === 'β€Ž06β€Ž/β€Ž25β€Ž/β€Ž2014β€Ž β€Ž00β€Ž:β€Ž00β€Ž:β€Ž00'; + dtfCache[timeZone] = hourCycleSupported ? new Intl.DateTimeFormat('en-US', { + hour12: false, + timeZone: timeZone, +diff --git a/node_modules/date-fns-tz/esm/_lib/tzTokenizeDate/index.js b/node_modules/date-fns-tz/esm/_lib/tzTokenizeDate/index.js +index cc1d143..17333cc 100644 +--- a/node_modules/date-fns-tz/esm/_lib/tzTokenizeDate/index.js ++++ b/node_modules/date-fns-tz/esm/_lib/tzTokenizeDate/index.js +@@ -48,23 +48,24 @@ function hackyOffset(dtf, date) { + // to get deterministic local date/time output according to the `en-US` locale which + // can be used to extract local time parts as necessary. + var dtfCache = {} ++ ++// New browsers use `hourCycle`, IE and Chrome <73 does not support it and uses `hour12` ++const testDateFormatted = new Intl.DateTimeFormat('en-US', { ++ hourCycle: 'h23', ++ timeZone: 'America/New_York', ++ year: 'numeric', ++ month: '2-digit', ++ day: '2-digit', ++ hour: '2-digit', ++ minute: '2-digit', ++ second: '2-digit', ++}).format(new Date('2014-06-25T04:00:00.123Z')) ++const hourCycleSupported = ++ testDateFormatted === '06/25/2014, 00:00:00' || ++ testDateFormatted === 'β€Ž06β€Ž/β€Ž25β€Ž/β€Ž2014β€Ž β€Ž00β€Ž:β€Ž00β€Ž:β€Ž00' ++ + function getDateTimeFormat(timeZone) { + if (!dtfCache[timeZone]) { +- // New browsers use `hourCycle`, IE and Chrome <73 does not support it and uses `hour12` +- var testDateFormatted = new Intl.DateTimeFormat('en-US', { +- hour12: false, +- timeZone: 'America/New_York', +- year: 'numeric', +- month: 'numeric', +- day: '2-digit', +- hour: '2-digit', +- minute: '2-digit', +- second: '2-digit', +- }).format(new Date('2014-06-25T04:00:00.123Z')) +- var hourCycleSupported = +- testDateFormatted === '06/25/2014, 00:00:00' || +- testDateFormatted === 'β€Ž06β€Ž/β€Ž25β€Ž/β€Ž2014β€Ž β€Ž00β€Ž:β€Ž00β€Ž:β€Ž00' +- + dtfCache[timeZone] = hourCycleSupported + ? new Intl.DateTimeFormat('en-US', { + hour12: false, diff --git a/src/CONST.ts b/src/CONST.ts index d0695b1e285..cf3facb0d1d 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -461,7 +461,6 @@ const CONST = { DEFAULT_ROOMS: 'defaultRooms', DUPE_DETECTION: 'dupeDetection', P2P_DISTANCE_REQUESTS: 'p2pDistanceRequests', - WORKFLOWS_ADVANCED_APPROVAL: 'workflowsAdvancedApproval', SPOTNANA_TRAVEL: 'spotnanaTravel', REPORT_FIELDS_FEATURE: 'reportFieldsFeature', WORKSPACE_FEEDS: 'workspaceFeeds', @@ -1019,6 +1018,9 @@ const CONST = { EXPORT_TO_INTEGRATION: 'exportToIntegration', MARK_AS_EXPORTED: 'markAsExported', }, + ROOM_MEMBERS_BULK_ACTION_TYPES: { + REMOVE: 'remove', + }, }, NEXT_STEP: { ICONS: { @@ -2338,6 +2340,15 @@ const CONST = { DEFAULT_MAX_EXPENSE_AGE: 90, DEFAULT_MAX_EXPENSE_AMOUNT: 200000, DEFAULT_MAX_AMOUNT_NO_RECEIPT: 2500, + REQUIRE_RECEIPTS_OVER_OPTIONS: { + DEFAULT: 'default', + NEVER: 'never', + ALWAYS: 'always', + }, + EXPENSE_LIMIT_TYPES: { + EXPENSE: 'expense', + DAILY: 'daily', + }, }, CUSTOM_UNITS: { @@ -4206,6 +4217,11 @@ const CONST = { */ MAX_SELECTION_LIST_PAGE_LENGTH: 500, + /** + * We only include the members search bar when we have 8 or more members + */ + SHOULD_SHOW_MEMBERS_SEARCH_INPUT_BREAKPOINT: 8, + /** * Bank account names */ diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 45b9a8c68bb..707c66b89c8 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -504,6 +504,10 @@ const ONYXKEYS = { WORKSPACE_SETTINGS_FORM: 'workspaceSettingsForm', WORKSPACE_CATEGORY_FORM: 'workspaceCategoryForm', WORKSPACE_CATEGORY_FORM_DRAFT: 'workspaceCategoryFormDraft', + WORKSPACE_CATEGORY_DESCRIPTION_HINT_FORM: 'workspaceCategoryDescriptionHintForm', + WORKSPACE_CATEGORY_DESCRIPTION_HINT_FORM_DRAFT: 'workspaceCategoryDescriptionHintFormDraft', + WORKSPACE_CATEGORY_FLAG_AMOUNTS_OVER_FORM: 'workspaceCategoryFlagAmountsOverForm', + WORKSPACE_CATEGORY_FLAG_AMOUNTS_OVER_FORM_DRAFT: 'workspaceCategoryFlagAmountsOverFormDraft', WORKSPACE_TAG_FORM: 'workspaceTagForm', WORKSPACE_TAG_FORM_DRAFT: 'workspaceTagFormDraft', WORKSPACE_SETTINGS_FORM_DRAFT: 'workspaceSettingsFormDraft', @@ -677,6 +681,8 @@ type OnyxFormValuesMapping = { [ONYXKEYS.FORMS.WORKSPACE_TAX_CUSTOM_NAME]: FormTypes.WorkspaceTaxCustomName; [ONYXKEYS.FORMS.WORKSPACE_COMPANY_CARD_FEED_NAME]: FormTypes.WorkspaceCompanyCardFeedName; [ONYXKEYS.FORMS.WORKSPACE_REPORT_FIELDS_FORM]: FormTypes.WorkspaceReportFieldForm; + [ONYXKEYS.FORMS.WORKSPACE_CATEGORY_DESCRIPTION_HINT_FORM]: FormTypes.WorkspaceCategoryDescriptionHintForm; + [ONYXKEYS.FORMS.WORKSPACE_CATEGORY_FLAG_AMOUNTS_OVER_FORM]: FormTypes.WorkspaceCategoryFlagAmountsOverForm; [ONYXKEYS.FORMS.CLOSE_ACCOUNT_FORM]: FormTypes.CloseAccountForm; [ONYXKEYS.FORMS.PROFILE_SETTINGS_FORM]: FormTypes.ProfileSettingsForm; [ONYXKEYS.FORMS.DISPLAY_NAME_FORM]: FormTypes.DisplayNameForm; diff --git a/src/ROUTES.ts b/src/ROUTES.ts index a28c2ef4fc5..f4eee3bb797 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -356,6 +356,10 @@ const ROUTES = { route: 'r/:reportID/members', getRoute: (reportID: string) => `r/${reportID}/members` as const, }, + ROOM_MEMBER_DETAILS: { + route: 'r/:reportID/members/:accountID', + getRoute: (reportID: string, accountID: string | number) => `r/${reportID}/members/${accountID}` as const, + }, ROOM_INVITE: { route: 'r/:reportID/invite/:role?', getRoute: (reportID: string, role?: string) => `r/${reportID}/invite/${role ?? ''}` as const, @@ -649,8 +653,8 @@ const ROUTES = { }, WORKSPACE_WORKFLOWS_APPROVALS_APPROVER: { route: 'settings/workspaces/:policyID/workflows/approvals/approver', - getRoute: (policyID: string, approverIndex?: number, backTo?: string) => - getUrlWithBackToParam(`settings/workspaces/${policyID}/workflows/approvals/approver${approverIndex !== undefined ? `?approverIndex=${approverIndex}` : ''}` as const, backTo), + getRoute: (policyID: string, approverIndex: number, backTo?: string) => + getUrlWithBackToParam(`settings/workspaces/${policyID}/workflows/approvals/approver?approverIndex=${approverIndex}` as const, backTo), }, WORKSPACE_WORKFLOWS_PAYER: { route: 'settings/workspaces/:policyID/workflows/payer', @@ -786,6 +790,26 @@ const ROUTES = { route: 'settings/workspaces/:policyID/categories/:categoryName/gl-code', getRoute: (policyID: string, categoryName: string) => `settings/workspaces/${policyID}/categories/${encodeURIComponent(categoryName)}/gl-code` as const, }, + WORSKPACE_CATEGORY_DEFAULT_TAX_RATE: { + route: 'settings/workspaces/:policyID/categories/:categoryName/tax-rate', + getRoute: (policyID: string, categoryName: string) => `settings/workspaces/${policyID}/categories/${encodeURIComponent(categoryName)}/tax-rate` as const, + }, + WORSKPACE_CATEGORY_FLAG_AMOUNTS_OVER: { + route: 'settings/workspaces/:policyID/categories/:categoryName/flag-amounts', + getRoute: (policyID: string, categoryName: string) => `settings/workspaces/${policyID}/categories/${encodeURIComponent(categoryName)}/flag-amounts` as const, + }, + WORSKPACE_CATEGORY_DESCRIPTION_HINT: { + route: 'settings/workspaces/:policyID/categories/:categoryName/description-hint', + getRoute: (policyID: string, categoryName: string) => `settings/workspaces/${policyID}/categories/${encodeURIComponent(categoryName)}/description-hint` as const, + }, + WORSKPACE_CATEGORY_REQUIRE_RECEIPTS_OVER: { + route: 'settings/workspaces/:policyID/categories/:categoryName/require-receipts-over', + getRoute: (policyID: string, categoryName: string) => `settings/workspaces/${policyID}/categories/${encodeURIComponent(categoryName)}/require-receipts-over` as const, + }, + WORSKPACE_CATEGORY_APPROVER: { + route: 'settings/workspaces/:policyID/categories/:categoryName/approver', + getRoute: (policyID: string, categoryName: string) => `settings/workspaces/${policyID}/categories/${encodeURIComponent(categoryName)}/approver` as const, + }, WORKSPACE_MORE_FEATURES: { route: 'settings/workspaces/:policyID/more-features', getRoute: (policyID: string) => `settings/workspaces/${policyID}/more-features` as const, diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 67ba5b84c9e..a0db6a121c3 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -160,6 +160,7 @@ const SCREENS = { SIGN_IN: 'SignIn', PRIVATE_NOTES: 'Private_Notes', ROOM_MEMBERS: 'RoomMembers', + ROOM_MEMBER_DETAILS: 'RoomMembers_Details', ROOM_INVITE: 'RoomInvite', REFERRAL: 'Referral', PROCESS_MONEY_REQUEST_HOLD: 'ProcessMoneyRequestHold', @@ -436,6 +437,11 @@ const SCREENS = { CATEGORY_PAYROLL_CODE: 'Category_Payroll_Code', CATEGORY_GL_CODE: 'Category_GL_Code', CATEGORY_SETTINGS: 'Category_Settings', + CATEGORY_DEFAULT_TAX_RATE: 'Category_Default_Tax_Rate', + CATEGORY_FLAG_AMOUNTS_OVER: 'Category_Flag_Amounts_Over', + CATEGORY_DESCRIPTION_HINT: 'Category_Description_Hint', + CATEGORY_APPROVER: 'Category_Approver', + CATEGORY_REQUIRE_RECEIPTS_OVER: 'Category_Require_Receipts_Over', CATEGORIES_SETTINGS: 'Categories_Settings', CATEGORIES_IMPORT: 'Categories_Import', CATEGORIES_IMPORTED: 'Categories_Imported', @@ -514,8 +520,11 @@ const SCREENS = { DETAILS: 'ReportParticipants_Details', ROLE: 'ReportParticipants_Role', }, - ROOM_MEMBERS_ROOT: 'RoomMembers_Root', - ROOM_INVITE_ROOT: 'RoomInvite_Root', + ROOM_MEMBERS: { + ROOT: 'RoomMembers_Root', + INVITE: 'RoomMembers_Invite', + DETAILS: 'RoomMember_Details', + }, FLAG_COMMENT_ROOT: 'FlagComment_Root', REIMBURSEMENT_ACCOUNT: 'ReimbursementAccount', GET_ASSISTANCE: 'GetAssistance', diff --git a/src/components/AmountForm.tsx b/src/components/AmountForm.tsx index f32d167e713..68def45e373 100644 --- a/src/components/AmountForm.tsx +++ b/src/components/AmountForm.tsx @@ -49,7 +49,7 @@ type AmountFormProps = { /** Whether the form should use a standard TextInput as a base */ displayAsTextInput?: boolean; } & Pick & - Pick; + Pick; /** * Returns the new selection object based on the updated amount's length @@ -69,6 +69,7 @@ function AmountForm( currency = CONST.CURRENCY.USD, extraDecimals = 0, amountMaxLength, + errorText, onInputChange, onCurrencyButtonPress, displayAsTextInput = false, @@ -297,11 +298,11 @@ function AmountForm( // eslint-disable-next-line react/jsx-props-no-spreading {...rest} /> - {!!rest.errorText && ( + {!errorText && ( )} diff --git a/src/components/Attachments/AttachmentView/index.tsx b/src/components/Attachments/AttachmentView/index.tsx index e9406db118e..6d14a41741a 100644 --- a/src/components/Attachments/AttachmentView/index.tsx +++ b/src/components/Attachments/AttachmentView/index.tsx @@ -153,7 +153,7 @@ function AttachmentView({ ); } - if (TransactionUtils.hasEReceipt(transaction) && transaction) { + if (transaction && !TransactionUtils.hasReceiptSource(transaction) && TransactionUtils.hasEReceipt(transaction)) { return ( ; +type RoomMemberBulkActionType = DeepValueOf; + type WorkspaceDistanceRatesBulkActionType = DeepValueOf; type WorkspaceTaxRatesBulkActionType = DeepValueOf; @@ -104,6 +106,7 @@ type ButtonWithDropdownMenuProps = { export type { PaymentType, WorkspaceMemberBulkActionType, + RoomMemberBulkActionType, WorkspaceDistanceRatesBulkActionType, DropdownOption, ButtonWithDropdownMenuProps, diff --git a/src/components/ImportSpreadsheet.tsx b/src/components/ImportSpreadsheet.tsx index fd75bfc2d3b..8aed242987f 100644 --- a/src/components/ImportSpreadsheet.tsx +++ b/src/components/ImportSpreadsheet.tsx @@ -167,7 +167,7 @@ function ImportSpreedsheet({backTo, goTo}: ImportSpreedsheetProps) { Navigation.navigate(backTo)} /> diff --git a/src/components/LHNOptionsList/LHNOptionsList.tsx b/src/components/LHNOptionsList/LHNOptionsList.tsx index a734890a1f3..568839d6c9a 100644 --- a/src/components/LHNOptionsList/LHNOptionsList.tsx +++ b/src/components/LHNOptionsList/LHNOptionsList.tsx @@ -22,6 +22,7 @@ import * as ReportActionsUtils from '@libs/ReportActionsUtils'; import variables from '@styles/variables'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import type {PersonalDetails} from '@src/types/onyx'; import OptionRowLHNData from './OptionRowLHNData'; import type {LHNOptionsListProps, RenderItemProps} from './types'; @@ -148,6 +149,20 @@ function LHNOptionsList({style, contentContainerStyles, data, onSelectRow, optio } const lastReportActionTransaction = transactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${lastReportActionTransactionID}`]; + // SidebarUtils.getOptionData in OptionRowLHNData does not get re-evaluated when the linked task report changes, so we have the lastMessageTextFromReport evaluation logic here + let lastActorDetails: Partial | null = + itemFullReport?.lastActorAccountID && personalDetails?.[itemFullReport.lastActorAccountID] ? personalDetails[itemFullReport.lastActorAccountID] : null; + if (!lastActorDetails && lastReportAction) { + const lastActorDisplayName = lastReportAction?.person?.[0]?.text; + lastActorDetails = lastActorDisplayName + ? { + displayName: lastActorDisplayName, + accountID: itemFullReport?.lastActorAccountID, + } + : null; + } + const lastMessageTextFromReport = OptionsListUtils.getLastMessageTextForReport(itemFullReport, lastActorDetails, itemPolicy); + return ( {}, opti const statusContent = formattedDate ? `${statusText ? `${statusText} ` : ''}(${formattedDate})` : statusText; const isStatusVisible = !!emojiCode && ReportUtils.isOneOnOneChat(!isEmptyObject(report) ? report : undefined); - const isGroupChat = ReportUtils.isGroupChat(optionItem) || ReportUtils.isDeprecatedGroupDM(optionItem); - - const fullTitle = isGroupChat ? ReportUtils.getGroupChatName(undefined, false, report) : optionItem.text; const subscriptAvatarBorderColor = isFocused ? focusedBackgroundColor : theme.sidebar; return ( {}, opti ) => void; diff --git a/src/components/MenuItem.tsx b/src/components/MenuItem.tsx index d37bf32843d..a376756df1d 100644 --- a/src/components/MenuItem.tsx +++ b/src/components/MenuItem.tsx @@ -449,6 +449,7 @@ function MenuItem( const combinedTitleTextStyle = StyleUtils.combineStyles( [ styles.flexShrink1, + styles.flex1, styles.popoverMenuText, // eslint-disable-next-line no-nested-ternary shouldPutLeftPaddingWhenNoIcon || (icon && !Array.isArray(icon)) ? (avatarSize === CONST.AVATAR_SIZE.SMALL ? styles.ml2 : styles.ml3) : {}, diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx index 57c5c01da55..a928eb7b6fd 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -194,8 +194,8 @@ function MoneyRequestView({ const canEditMerchant = canUserPerformWriteAction && ReportUtils.canEditFieldOfMoneyRequest(parentReportAction, CONST.EDIT_REQUEST_FIELD.MERCHANT); const canEditDate = canUserPerformWriteAction && ReportUtils.canEditFieldOfMoneyRequest(parentReportAction, CONST.EDIT_REQUEST_FIELD.DATE); const canEditReceipt = canUserPerformWriteAction && ReportUtils.canEditFieldOfMoneyRequest(parentReportAction, CONST.EDIT_REQUEST_FIELD.RECEIPT); - const hasReceipt = TransactionUtils.hasReceipt(transaction); - const isReceiptBeingScanned = hasReceipt && TransactionUtils.isReceiptBeingScanned(transaction); + const hasReceipt = TransactionUtils.hasReceipt(updatedTransaction ?? transaction); + const isReceiptBeingScanned = hasReceipt && TransactionUtils.isReceiptBeingScanned(updatedTransaction ?? transaction); const didReceiptScanSucceed = hasReceipt && TransactionUtils.didReceiptScanSucceed(transaction); const canEditDistance = canUserPerformWriteAction && ReportUtils.canEditFieldOfMoneyRequest(parentReportAction, CONST.EDIT_REQUEST_FIELD.DISTANCE); const canEditDistanceRate = canUserPerformWriteAction && ReportUtils.canEditFieldOfMoneyRequest(parentReportAction, CONST.EDIT_REQUEST_FIELD.DISTANCE_RATE); @@ -257,6 +257,8 @@ function MoneyRequestView({ } return TransactionUtils.getDescription(updatedTransaction ?? null); }, [updatedTransaction]); + const isEmptyUpdatedMerchant = updatedTransaction?.modifiedMerchant === '' || updatedTransaction?.modifiedMerchant === CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT; + const updatedMerchantTitle = isEmptyUpdatedMerchant ? '' : updatedTransaction?.modifiedMerchant ?? merchantTitle; const saveBillable = useCallback( (newBillable: boolean) => { @@ -292,7 +294,7 @@ function MoneyRequestView({ let receiptURIs; const hasErrors = TransactionUtils.hasMissingSmartscanFields(transaction); if (hasReceipt) { - receiptURIs = ReceiptUtils.getThumbnailAndImageURIs(transaction); + receiptURIs = ReceiptUtils.getThumbnailAndImageURIs(updatedTransaction ?? transaction); } const pendingAction = transaction?.pendingAction; const getPendingFieldAction = (fieldPath: TransactionPendingFieldsKey) => transaction?.pendingFields?.[fieldPath] ?? pendingAction; @@ -496,7 +498,7 @@ function MoneyRequestView({ image={receiptURIs?.image} isLocalFile={receiptURIs?.isLocalFile} filename={receiptURIs?.filename} - transaction={transaction} + transaction={updatedTransaction ?? transaction} enablePreviewModal readonly={readonly} /> @@ -562,7 +564,7 @@ function MoneyRequestView({ 0 ? ` ${taskTitle}` : `${taskTitle}`; + const [avatar] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {selector: (personalDetails) => personalDetails?.[taskAssigneeAccountID]?.avatar}); + const htmlForTaskPreview = `${taskTitle}`; const isDeletedParentAction = ReportUtils.isCanceledTaskReport(taskReport, action); if (isDeletedParentAction) { @@ -94,7 +95,7 @@ function TaskPreview({taskReport, taskReportID, action, contextMenuAnchor, chatR accessibilityLabel={translate('task.task')} > - + - ${htmlForTaskPreview}` : htmlForTaskPreview} /> + {taskAssigneeAccountID > 0 && ( + + )} + + ${htmlForTaskPreview}` : htmlForTaskPreview} /> + ( shouldDelayFocus = true, shouldUpdateFocusedIndex = false, onLongPressRow, + shouldShowTextInput = !!textInputLabel || !!textInputIconLeft, shouldShowListEmptyContent = true, }: BaseSelectionListProps, ref: ForwardedRef, @@ -108,7 +109,6 @@ function BaseSelectionList( const listRef = useRef>>(null); const innerTextInputRef = useRef(null); const focusTimeoutRef = useRef(null); - const shouldShowTextInput = !!textInputLabel || !!textInputIconLeft; const shouldShowSelectAll = !!onSelectAll; const activeElementRole = useActiveElementRole(); const isFocused = useIsFocused(); diff --git a/src/components/SelectionList/Search/TransactionListItemRow.tsx b/src/components/SelectionList/Search/TransactionListItemRow.tsx index 92666a8081d..38420c47a8f 100644 --- a/src/components/SelectionList/Search/TransactionListItemRow.tsx +++ b/src/components/SelectionList/Search/TransactionListItemRow.tsx @@ -1,3 +1,4 @@ +import {Str} from 'expensify-common'; import React from 'react'; import type {StyleProp, ViewStyle} from 'react-native'; import {View} from 'react-native'; @@ -15,7 +16,9 @@ import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import * as CurrencyUtils from '@libs/CurrencyUtils'; import DateUtils from '@libs/DateUtils'; +import {getFileName} from '@libs/fileDownload/FileUtils'; import Parser from '@libs/Parser'; +import {getThumbnailAndImageURIs} from '@libs/ReceiptUtils'; import StringUtils from '@libs/StringUtils'; import * as TransactionUtils from '@libs/TransactionUtils'; import tryResolveUrlFromApiRoot from '@libs/tryResolveUrlFromApiRoot'; @@ -81,6 +84,11 @@ function ReceiptCell({transactionItem}: TransactionCellProps) { const isViewAction = transactionItem.action === CONST.SEARCH.ACTION_TYPES.VIEW; const canModifyReceipt = isViewAction && transactionItem.canDelete; + const filename = getFileName(transactionItem?.receipt?.source ?? ''); + const receiptURIs = getThumbnailAndImageURIs(transactionItem, null, filename); + const isReceiptPDF = Str.isPDF(filename); + const source = tryResolveUrlFromApiRoot(isReceiptPDF && !receiptURIs.isLocalFile ? receiptURIs.thumbnail ?? '' : receiptURIs.image ?? ''); + return ( = Partial & { /** Callback to fire when an error is dismissed */ onDismissError?: (item: TItem) => void; + /** Whether to show the text input */ + shouldShowTextInput?: boolean; + /** Label for the text input */ textInputLabel?: string; diff --git a/src/components/TabSelector/TabSelectorItem.tsx b/src/components/TabSelector/TabSelectorItem.tsx index 4117046d86e..274813d9a44 100644 --- a/src/components/TabSelector/TabSelectorItem.tsx +++ b/src/components/TabSelector/TabSelectorItem.tsx @@ -39,12 +39,13 @@ function TabSelectorItem({icon, title = '', onPress = () => {}, backgroundColor return ( setIsHovered(true)} onHoverOut={() => setIsHovered(false)} role={CONST.ROLE.BUTTON} + dataSet={{[CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT]: true}} > >; + +type WorkspaceMembersSelectionListProps = { + policyID: string; + selectedApprover: string; + setApprover: (email: string) => void; +}; + +function WorkspaceMembersSelectionList({policyID, selectedApprover, setApprover}: WorkspaceMembersSelectionListProps) { + const {translate} = useLocalize(); + const {didScreenTransitionEnd} = useScreenWrapperTranstionStatus(); + const [searchTerm, debouncedSearchTerm, setSearchTerm] = useDebouncedState(''); + const personalDetails = usePersonalDetails(); + const policy = usePolicy(policyID); + + const sections: ApproverSection[] = useMemo(() => { + const approvers: SelectionListApprover[] = []; + + if (policy?.employeeList) { + const availableApprovers = Object.values(policy.employeeList) + .map((employee): SelectionListApprover | null => { + const isAdmin = employee?.role === CONST.REPORT.ROLE.ADMIN; + const email = employee.email; + + if (!email) { + return null; + } + + const policyMemberEmailsToAccountIDs = PolicyUtils.getMemberAccountIDsForWorkspace(policy?.employeeList); + const accountID = Number(policyMemberEmailsToAccountIDs[email] ?? ''); + const {avatar, displayName = email} = personalDetails?.[accountID] ?? {}; + + return { + text: displayName, + alternateText: email, + keyForList: email, + isSelected: selectedApprover === email, + login: email, + icons: [{source: avatar ?? FallbackAvatar, type: CONST.ICON_TYPE_AVATAR, name: displayName, id: accountID}], + rightElement: isAdmin ? : undefined, + }; + }) + .filter((approver): approver is SelectionListApprover => !!approver); + + approvers.push(...availableApprovers); + } + + const filteredApprovers = + debouncedSearchTerm !== '' + ? approvers.filter((option) => { + const searchValue = OptionsListUtils.getSearchValueForPhoneOrEmail(debouncedSearchTerm); + const isPartOfSearchTerm = !!option.text?.toLowerCase().includes(searchValue) || !!option.login?.toLowerCase().includes(searchValue); + return isPartOfSearchTerm; + }) + : approvers; + + return [ + { + title: undefined, + data: OptionsListUtils.sortAlphabetically(filteredApprovers, 'text'), + shouldShow: true, + }, + ]; + }, [debouncedSearchTerm, personalDetails, policy?.employeeList, selectedApprover, translate]); + + const handleOnSelectRow = (approver: SelectionListApprover) => { + setApprover(approver.login); + }; + + const headerMessage = useMemo(() => (searchTerm && !sections[0].data.length ? translate('common.noResultsFound') : ''), [searchTerm, sections, translate]); + + return ( + + ); +} + +export default WorkspaceMembersSelectionList; diff --git a/src/languages/en.ts b/src/languages/en.ts index 6ef5390d4dc..38784f14a77 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -389,7 +389,6 @@ export default { ignore: 'Ignore', enabled: 'Enabled', import: 'Import', - importSpreadsheet: 'Import spreadsheet', offlinePrompt: "You can't take this action right now.", outstanding: 'Outstanding', chats: 'Chats', @@ -550,16 +549,6 @@ export default { sendAttachment: 'Send attachment', addAttachment: 'Add attachment', writeSomething: 'Write something...', - conciergePlaceholderOptions: [ - 'Ask for help!', - 'Ask me anything!', - 'Ask me to book travel!', - 'Ask me what I can do!', - 'Ask me how to pay people!', - 'Ask me how to send an invoice!', - 'Ask me how to scan a receipt!', - 'Ask me how to get a free corporate card!', - ], blockedFromConcierge: 'Communication is barred', fileUploadFailed: 'Upload failed. File is not supported.', localTime: ({user, time}: LocalTimeParams) => `It's ${time} for ${user}`, @@ -697,6 +686,8 @@ export default { importFailedDescription: 'Please ensure all fields are filled out correctly and try again. If the problem persists, please reach out to Concierge.', invalidFileMessage: 'The file you uploaded is either empty or contains invalid data. Please ensure that the file is correctly formatted and contains the necessary information before uploading it again.', + importSpreadsheet: 'Import spreadsheet', + downloadCSV: 'Download CSV', }, receipt: { upload: 'Upload receipt', @@ -1526,7 +1517,6 @@ export default { groupName: 'Group name', }, groupChat: { - groupMembersListTitle: 'Directory of all group members.', lastMemberTitle: 'Heads up!', lastMemberWarning: "Since you're the last person here, leaving will make this chat inaccessible to all users. Are you sure you want to leave?", defaultReportName: ({displayName}: {displayName: string}) => `${displayName}'s group chat`, @@ -3171,8 +3161,9 @@ export default { removeMembersWarningPrompt: ({memberName, ownerName}: RemoveMembersWarningPrompt) => `${memberName} is an approver in this workspace. When you unshare this workspace with them, we’ll replace them in the approval workflow with the workspace owner, ${ownerName}`, removeMembersTitle: 'Remove members', - removeMemberButtonTitle: 'Remove from workspace', - removeMemberGroupButtonTitle: 'Remove from group', + removeWorkspaceMemberButtonTitle: 'Remove from workspace', + removeGroupMemberButtonTitle: 'Remove from group', + removeRoomMemberButtonTitle: 'Remove from chat', removeMemberPrompt: ({memberName}: {memberName: string}) => `Are you sure you want to remove ${memberName}?`, removeMemberTitle: 'Remove member', transferOwner: 'Transfer owner', @@ -3220,7 +3211,7 @@ export default { fixedAmountDescription: 'Spend up to a certain amount once', setLimit: 'Set a limit', giveItName: 'Give it a name', - giveItNameInstruction: 'Make it unique enough to tell apart from the other. Specific use cases are even better!', + giveItNameInstruction: 'Make it unique enough to tell apart from other cards. Specific use cases are even better!', cardName: 'Card name', letsDoubleCheck: 'Let’s double check that everything looks right.', willBeReady: 'This card will be ready to use immediately.', @@ -3573,6 +3564,8 @@ export default { continueWithSetup: 'Continue with setup', youreAlmostDone: "You're almost done setting up your bank account, which will let you issue corporate cards, reimburse expenses, collect invoices, and pay bills.", streamlinePayments: 'Streamline payments', + connectBankAccountNote: "Note: Personal bank accounts can't be used for payments on workspaces.", + oneMoreThing: 'One more thing!', allSet: "You're all set!", accountDescriptionWithCards: 'This bank account will be used to issue corporate cards, reimburse expenses, collect invoices, and pay bills.', letsFinishInChat: "Let's finish in chat!", @@ -3772,6 +3765,35 @@ export default { unlockFeatureGoToSubtitle: 'Go to', unlockFeatureEnableWorkflowsSubtitle: (featureName: string) => `and enable workflows, then add ${featureName} to unlock this feature.`, }, + categoryRules: { + title: 'Category rules', + approver: 'Approver', + requireDescription: 'Require description', + descriptionHint: 'Description hint', + descriptionHintDescription: (categoryName: string) => + `Remind employees to provide additional information for β€œ${categoryName}” spend. This hint appears in the description field on expenses.`, + descriptionHintLabel: 'Hint', + descriptionHintSubtitle: 'Pro-tip: The shorter the better!', + maxAmount: 'Max amount', + flagAmountsOver: 'Flag amounts over', + flagAmountsOverDescription: (categoryName) => `Applies to the category β€œ${categoryName}”.`, + flagAmountsOverSubtitle: 'This overrides the max amount for all expenses.', + expenseLimitTypes: { + expense: 'Individual expense', + expenseSubtitle: 'Flag expense amounts by category. This rule overrides the general workspace rule for max expense amount.', + daily: 'Category total', + dailySubtitle: 'Flag total category spend per expense report.', + }, + requireReceiptsOver: 'Require receipts over', + requireReceiptsOverList: { + default: (defaultAmount: string) => `${defaultAmount} ${CONST.DOT_SEPARATOR} Default`, + never: 'Never require receipts', + always: 'Always require receipts', + }, + defaultTaxRate: 'Default tax rate', + goTo: 'Go to', + andEnableWorkflows: 'and enable workflows, then add approvals to unlock this feature.', + }, }, }, getAssistancePage: { diff --git a/src/languages/es.ts b/src/languages/es.ts index e4ebf709baf..a56204de6fe 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -373,7 +373,6 @@ export default { filterLogs: 'Registros de filtrado', network: 'La red', reportID: 'ID del informe', - importSpreadsheet: 'Importar hoja de cΓ‘lculo', chooseFile: 'Elegir archivo', dropTitle: 'SuΓ©ltalo', dropMessage: 'Suelta tu archivo aquΓ­', @@ -541,16 +540,6 @@ export default { sendAttachment: 'Enviar adjunto', addAttachment: 'AΓ±adir archivo adjunto', writeSomething: 'Escribe algo...', - conciergePlaceholderOptions: [ - 'Β‘Pide ayuda!', - 'Β‘PregΓΊntame lo que sea!', - 'Β‘PΓ­deme que te reserve un viaje!', - 'Β‘PregΓΊntame quΓ© puedo hacer!', - 'Β‘PregΓΊntame cΓ³mo pagar a la gente!', - 'Β‘PregΓΊntame cΓ³mo enviar una factura!', - 'Β‘PregΓΊntame cΓ³mo escanear un recibo!', - 'Β‘PregΓΊntame cΓ³mo obtener una tarjeta de crΓ©dito corporativa gratis!', - ], blockedFromConcierge: 'ComunicaciΓ³n no permitida', fileUploadFailed: 'Subida fallida. El archivo no es compatible.', localTime: ({user, time}: LocalTimeParams) => `Son las ${time} para ${user}`, @@ -690,6 +679,8 @@ export default { importSuccessfullTitle: 'Importar categorΓ­as', invalidFileMessage: 'El archivo que ha cargado estΓ‘ vacΓ­o o contiene datos no vΓ‘lidos. AsegΓΊrese de que el archivo tiene el formato correcto y contiene la informaciΓ³n necesaria antes de volver a cargarlo.', + importSpreadsheet: 'Importar hoja de cΓ‘lculo', + downloadCSV: 'Descargar CSV', }, receipt: { upload: 'Subir recibo', @@ -1535,7 +1526,6 @@ export default { groupName: 'Nombre del grupo', }, groupChat: { - groupMembersListTitle: 'Directorio de los miembros del grupo.', lastMemberTitle: 'Β‘AtenciΓ³n!', lastMemberWarning: 'Ya que eres la ΓΊltima persona aquΓ­, si te vas, este chat quedarΓ‘ inaccesible para todos los miembros. ΒΏEstΓ‘s seguro de que quieres salir del chat?', defaultReportName: ({displayName}: {displayName: string}) => `Chat de grupo de ${displayName}`, @@ -3220,8 +3210,9 @@ export default { removeMembersWarningPrompt: ({memberName, ownerName}: RemoveMembersWarningPrompt) => `${memberName} es un aprobador en este espacio de trabajo. Cuando lo elimine de este espacio de trabajo, los sustituiremos en el flujo de trabajo de aprobaciΓ³n por el propietario del espacio de trabajo, ${ownerName}`, removeMembersTitle: 'Eliminar miembros', - removeMemberButtonTitle: 'Quitar del espacio de trabajo', - removeMemberGroupButtonTitle: 'Quitar del grupo', + removeWorkspaceMemberButtonTitle: 'Eliminar del espacio de trabajo', + removeGroupMemberButtonTitle: 'Eliminar del grupo', + removeRoomMemberButtonTitle: 'Eliminar del chat', removeMemberPrompt: ({memberName}: {memberName: string}) => `ΒΏEstΓ‘s seguro de que deseas eliminar a ${memberName}?`, removeMemberTitle: 'Eliminar miembro', transferOwner: 'Transferir la propiedad', @@ -3480,7 +3471,7 @@ export default { fixedAmountDescription: 'Gasta hasta una determinada cantidad una vez', setLimit: 'Establecer un lΓ­mite', giveItName: 'Dale un nombre', - giveItNameInstruction: 'Hazlo lo suficientemente ΓΊnico como para distinguirlo de los demΓ‘s. Los casos de uso especΓ­ficos son aΓΊn mejores.', + giveItNameInstruction: 'Hazlo lo suficientemente ΓΊnico para distinguirla de otras tarjetas. Β‘Los casos de uso especΓ­ficos son aΓΊn mejores!', cardName: 'Nombre de la tarjeta', letsDoubleCheck: 'Vuelve a comprobar que todo parece correcto. ', willBeReady: 'Esta tarjeta estarΓ‘ lista para su uso inmediato.', @@ -3621,6 +3612,8 @@ export default { continueWithSetup: 'Continuar con la configuraciΓ³n', youreAlmostDone: 'Casi has acabado de configurar tu cuenta bancaria, que te permitirΓ‘ emitir tarjetas corporativas, reembolsar gastos y cobrar pagar facturas.', streamlinePayments: 'Optimiza pagos', + connectBankAccountNote: 'Nota: No se pueden usar cuentas bancarias personales para realizar pagos en los espacios de trabajo.', + oneMoreThing: 'Β‘Una cosa mΓ‘s!', allSet: 'Β‘Todo listo!', accountDescriptionWithCards: 'Esta cuenta bancaria se utilizarΓ‘ para emitir tarjetas corporativas, reembolsar gastos y cobrar y pagar facturas.', letsFinishInChat: 'Β‘Continuemos en el chat!', @@ -3822,6 +3815,35 @@ export default { unlockFeatureGoToSubtitle: 'Ir a', unlockFeatureEnableWorkflowsSubtitle: (featureName: string) => `y habilita flujos de trabajo, luego agrega ${featureName} para desbloquear esta funciΓ³n.`, }, + categoryRules: { + title: 'Reglas de categorΓ­a', + approver: 'Aprobador', + requireDescription: 'Requerir descripciΓ³n', + descriptionHint: 'Sugerencia de descripciΓ³n', + descriptionHintDescription: (categoryName: string) => + `Recuerda a los empleados que deben proporcionar informaciΓ³n adicional para los gastos de β€œ${categoryName}”. Esta sugerencia aparece en el campo de descripciΓ³n en los gastos.`, + descriptionHintLabel: 'Sugerencia', + descriptionHintSubtitle: 'Consejo: Β‘Cuanto mΓ‘s corta, mejor!', + maxAmount: 'Importe mΓ‘ximo', + flagAmountsOver: 'SeΓ±ala importes superiores a', + flagAmountsOverDescription: (categoryName: string) => `Aplica a la categorΓ­a β€œ${categoryName}”.`, + flagAmountsOverSubtitle: 'Esto anula el importe mΓ‘ximo para todos los gastos.', + expenseLimitTypes: { + expense: 'Gasto individual', + expenseSubtitle: 'SeΓ±ala importes de gastos por categorΓ­a. Esta regla anula la regla general del espacio de trabajo para el importe mΓ‘ximo de gastos.', + daily: 'Total por categorΓ­a', + dailySubtitle: 'Marcar el gasto total por categorΓ­a en cada informe de gastos.', + }, + requireReceiptsOver: 'Requerir recibos para importes superiores a', + requireReceiptsOverList: { + default: (defaultAmount: string) => `${defaultAmount} ${CONST.DOT_SEPARATOR} Predeterminado`, + never: 'Nunca requerir recibos', + always: 'Requerir recibos siempre', + }, + defaultTaxRate: 'Tasa de impuesto predeterminada', + goTo: 'Ve a', + andEnableWorkflows: 'y habilita los flujos de trabajo, luego aΓ±ade aprobaciones para desbloquear esta funciΓ³n.', + }, }, }, getAssistancePage: { diff --git a/src/libs/API/parameters/ExportCategoriesSpreadsheet.ts b/src/libs/API/parameters/ExportCategoriesSpreadsheet.ts new file mode 100644 index 00000000000..e62cbe684cf --- /dev/null +++ b/src/libs/API/parameters/ExportCategoriesSpreadsheet.ts @@ -0,0 +1,6 @@ +type ExportCategoriesSpreadsheetParams = { + /** ID of the policy */ + policyID: string; +}; + +export default ExportCategoriesSpreadsheetParams; diff --git a/src/libs/API/parameters/RemovePolicyCategoryReceiptsRequiredParams.ts b/src/libs/API/parameters/RemovePolicyCategoryReceiptsRequiredParams.ts new file mode 100644 index 00000000000..83e62db5981 --- /dev/null +++ b/src/libs/API/parameters/RemovePolicyCategoryReceiptsRequiredParams.ts @@ -0,0 +1,6 @@ +type RemovePolicyCategoryReceiptsRequiredParams = { + policyID: string; + categoryName: string; +}; + +export default RemovePolicyCategoryReceiptsRequiredParams; diff --git a/src/libs/API/parameters/SetPolicyCategoryApproverParams.ts b/src/libs/API/parameters/SetPolicyCategoryApproverParams.ts new file mode 100644 index 00000000000..197fdaf59df --- /dev/null +++ b/src/libs/API/parameters/SetPolicyCategoryApproverParams.ts @@ -0,0 +1,7 @@ +type SetPolicyCategoryApproverParams = { + policyID: string; + categoryName: string; + approver: string; +}; + +export default SetPolicyCategoryApproverParams; diff --git a/src/libs/API/parameters/SetPolicyCategoryDescriptionRequiredParams.ts b/src/libs/API/parameters/SetPolicyCategoryDescriptionRequiredParams.ts new file mode 100644 index 00000000000..6a1748ff9ad --- /dev/null +++ b/src/libs/API/parameters/SetPolicyCategoryDescriptionRequiredParams.ts @@ -0,0 +1,7 @@ +type SetPolicyCategoryDescriptionRequiredParams = { + policyID: string; + categoryName: string; + areCommentsRequired: boolean; +}; + +export default SetPolicyCategoryDescriptionRequiredParams; diff --git a/src/libs/API/parameters/SetPolicyCategoryMaxAmountParams.ts b/src/libs/API/parameters/SetPolicyCategoryMaxAmountParams.ts new file mode 100644 index 00000000000..6132f0a69b1 --- /dev/null +++ b/src/libs/API/parameters/SetPolicyCategoryMaxAmountParams.ts @@ -0,0 +1,10 @@ +import type {PolicyCategoryExpenseLimitType} from '@src/types/onyx/PolicyCategory'; + +type SetPolicyCategoryMaxAmountParams = { + policyID: string; + categoryName: string; + maxExpenseAmount: number | null; + expenseLimitType: PolicyCategoryExpenseLimitType; +}; + +export default SetPolicyCategoryMaxAmountParams; diff --git a/src/libs/API/parameters/SetPolicyCategoryReceiptsRequiredParams.ts b/src/libs/API/parameters/SetPolicyCategoryReceiptsRequiredParams.ts new file mode 100644 index 00000000000..fe7c15bd8ef --- /dev/null +++ b/src/libs/API/parameters/SetPolicyCategoryReceiptsRequiredParams.ts @@ -0,0 +1,7 @@ +type SetPolicyCategoryReceiptsRequiredParams = { + policyID: string; + categoryName: string; + maxExpenseAmountNoReceipt: number; +}; + +export default SetPolicyCategoryReceiptsRequiredParams; diff --git a/src/libs/API/parameters/SetPolicyCategoryTaxParams.ts b/src/libs/API/parameters/SetPolicyCategoryTaxParams.ts new file mode 100644 index 00000000000..94a0a602591 --- /dev/null +++ b/src/libs/API/parameters/SetPolicyCategoryTaxParams.ts @@ -0,0 +1,7 @@ +type SetPolicyCategoryTaxParams = { + policyID: string; + categoryName: string; + taxID: string; +}; + +export default SetPolicyCategoryTaxParams; diff --git a/src/libs/API/parameters/SetWorkspaceCategoryDescriptionHintParams.ts b/src/libs/API/parameters/SetWorkspaceCategoryDescriptionHintParams.ts new file mode 100644 index 00000000000..d1c3b36975c --- /dev/null +++ b/src/libs/API/parameters/SetWorkspaceCategoryDescriptionHintParams.ts @@ -0,0 +1,7 @@ +type SetWorkspaceCategoryDescriptionHintParams = { + policyID: string; + categoryName: string; + commentHint: string; +}; + +export default SetWorkspaceCategoryDescriptionHintParams; diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts index 4cc90f8ae54..c6cc50df300 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -295,12 +295,20 @@ export type {default as ConfigureExpensifyCardsForPolicyParams} from './Configur export type {default as CreateExpensifyCardParams} from './CreateExpensifyCardParams'; export type {default as UpdateExpensifyCardTitleParams} from './UpdateExpensifyCardTitleParams'; export type {default as OpenCardDetailsPageParams} from './OpenCardDetailsPageParams'; +export type {default as SetPolicyCategoryDescriptionRequiredParams} from './SetPolicyCategoryDescriptionRequiredParams'; +export type {default as SetPolicyCategoryApproverParams} from './SetPolicyCategoryApproverParams'; +export type {default as SetWorkspaceCategoryDescriptionHintParams} from './SetWorkspaceCategoryDescriptionHintParams'; +export type {default as SetPolicyCategoryTaxParams} from './SetPolicyCategoryTaxParams'; +export type {default as SetPolicyCategoryMaxAmountParams} from './SetPolicyCategoryMaxAmountParams'; export type {default as EnablePolicyCompanyCardsParams} from './EnablePolicyCompanyCardsParams'; export type {default as ToggleCardContinuousReconciliationParams} from './ToggleCardContinuousReconciliationParams'; export type {default as CardDeactivateParams} from './CardDeactivateParams'; export type {default as UpdateExpensifyCardLimitTypeParams} from './UpdateExpensifyCardLimitTypeParams'; +export type {default as SetPolicyCategoryReceiptsRequiredParams} from './SetPolicyCategoryReceiptsRequiredParams'; +export type {default as RemovePolicyCategoryReceiptsRequiredParams} from './RemovePolicyCategoryReceiptsRequiredParams'; export type {default as UpdateQuickbooksOnlineAutoCreateVendorParams} from './UpdateQuickbooksOnlineAutoCreateVendorParams'; export type {default as ImportCategoriesSpreadsheetParams} from './ImportCategoriesSpreadsheet'; +export type {default as ExportCategoriesSpreadsheetParams} from './ExportCategoriesSpreadsheet'; export type {default as UpdateXeroGenericTypeParams} from './UpdateXeroGenericTypeParams'; export type {default as UpdateCardSettlementFrequencyParams} from './UpdateCardSettlementFrequencyParams'; export type {default as UpdateCardSettlementAccountParams} from './UpdateCardSettlementAccountParams'; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index 5be6cc08d81..a59bffca1dd 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -142,7 +142,8 @@ const WRITE_COMMANDS = { SET_WORKSPACE_CATEGORIES_ENABLED: 'SetWorkspaceCategoriesEnabled', SET_POLICY_TAGS_ENABLED: 'SetPolicyTagsEnabled', CREATE_WORKSPACE_CATEGORIES: 'CreateWorkspaceCategories', - IMPORT_CATEGORIES_SREADSHEET: 'ImportCategoriesSpreadsheet', + IMPORT_CATEGORIES_SPREADSHEET: 'ImportCategoriesSpreadsheet', + EXPORT_CATEGORIES_CSV: 'ExportCategoriesCSV', RENAME_WORKSPACE_CATEGORY: 'RenameWorkspaceCategory', CREATE_POLICY_TAG: 'CreatePolicyTag', RENAME_POLICY_TAG: 'RenamePolicyTag', @@ -221,6 +222,13 @@ const WRITE_COMMANDS = { SET_POLICY_BILLABLE_MODE: ' SetPolicyBillableMode', DISABLE_POLICY_BILLABLE_MODE: 'DisablePolicyBillableExpenses', SET_WORKSPACE_ERECEIPTS_ENABLED: 'SetWorkspaceEReceiptsEnabled', + SET_POLICY_CATEGORY_DESCRIPTION_REQUIRED: 'SetPolicyCategoryDescriptionRequired', + SET_WORKSPACE_CATEGORY_DESCRIPTION_HINT: 'SetWorkspaceCategoryDescriptionHint', + SET_POLICY_CATEGORY_RECEIPTS_REQUIRED: 'SetPolicyCategoryReceiptsRequired', + REMOVE_POLICY_CATEGORY_RECEIPTS_REQUIRED: 'RemoveWorkspaceCategoryReceiptsRequired', + SET_POLICY_CATEGORY_MAX_AMOUNT: 'SetPolicyCategoryMaxAmount', + SET_POLICY_CATEGORY_APPROVER: 'SetPolicyCategoryApprover', + SET_POLICY_CATEGORY_TAX: 'SetPolicyCategoryTax', SET_POLICY_TAXES_CURRENCY_DEFAULT: 'SetPolicyCurrencyDefaultTax', SET_POLICY_TAXES_FOREIGN_CURRENCY_DEFAULT: 'SetPolicyForeignCurrencyDefaultTax', SET_POLICY_CUSTOM_TAX_NAME: 'SetPolicyCustomTaxName', @@ -494,7 +502,8 @@ type WriteCommandParameters = { [WRITE_COMMANDS.CREATE_WORKSPACE_FROM_IOU_PAYMENT]: Parameters.CreateWorkspaceFromIOUPaymentParams; [WRITE_COMMANDS.SET_WORKSPACE_CATEGORIES_ENABLED]: Parameters.SetWorkspaceCategoriesEnabledParams; [WRITE_COMMANDS.CREATE_WORKSPACE_CATEGORIES]: Parameters.CreateWorkspaceCategoriesParams; - [WRITE_COMMANDS.IMPORT_CATEGORIES_SREADSHEET]: Parameters.ImportCategoriesSpreadsheetParams; + [WRITE_COMMANDS.IMPORT_CATEGORIES_SPREADSHEET]: Parameters.ImportCategoriesSpreadsheetParams; + [WRITE_COMMANDS.EXPORT_CATEGORIES_CSV]: Parameters.ExportCategoriesSpreadsheetParams; [WRITE_COMMANDS.RENAME_WORKSPACE_CATEGORY]: Parameters.RenameWorkspaceCategoriesParams; [WRITE_COMMANDS.SET_WORKSPACE_REQUIRES_CATEGORY]: Parameters.SetWorkspaceRequiresCategoryParams; [WRITE_COMMANDS.DELETE_WORKSPACE_CATEGORIES]: Parameters.DeleteWorkspaceCategoriesParams; @@ -583,6 +592,13 @@ type WriteCommandParameters = { [WRITE_COMMANDS.ENABLE_POLICY_COMPANY_CARDS]: Parameters.EnablePolicyCompanyCardsParams; [WRITE_COMMANDS.ENABLE_POLICY_INVOICING]: Parameters.EnablePolicyInvoicingParams; [WRITE_COMMANDS.SET_POLICY_RULES_ENABLED]: Parameters.SetPolicyRulesEnabledParams; + [WRITE_COMMANDS.SET_POLICY_CATEGORY_DESCRIPTION_REQUIRED]: Parameters.SetPolicyCategoryDescriptionRequiredParams; + [WRITE_COMMANDS.SET_WORKSPACE_CATEGORY_DESCRIPTION_HINT]: Parameters.SetWorkspaceCategoryDescriptionHintParams; + [WRITE_COMMANDS.SET_POLICY_CATEGORY_RECEIPTS_REQUIRED]: Parameters.SetPolicyCategoryReceiptsRequiredParams; + [WRITE_COMMANDS.REMOVE_POLICY_CATEGORY_RECEIPTS_REQUIRED]: Parameters.RemovePolicyCategoryReceiptsRequiredParams; + [WRITE_COMMANDS.SET_POLICY_CATEGORY_MAX_AMOUNT]: Parameters.SetPolicyCategoryMaxAmountParams; + [WRITE_COMMANDS.SET_POLICY_CATEGORY_APPROVER]: Parameters.SetPolicyCategoryApproverParams; + [WRITE_COMMANDS.SET_POLICY_CATEGORY_TAX]: Parameters.SetPolicyCategoryTaxParams; [WRITE_COMMANDS.JOIN_POLICY_VIA_INVITE_LINK]: Parameters.JoinPolicyInviteLinkParams; [WRITE_COMMANDS.ACCEPT_JOIN_REQUEST]: Parameters.AcceptJoinRequestParams; [WRITE_COMMANDS.DECLINE_JOIN_REQUEST]: Parameters.DeclineJoinRequestParams; diff --git a/src/libs/CategoryUtils.ts b/src/libs/CategoryUtils.ts new file mode 100644 index 00000000000..7f971f37d3f --- /dev/null +++ b/src/libs/CategoryUtils.ts @@ -0,0 +1,62 @@ +import type {LocaleContextProps} from '@components/LocaleContextProvider'; +import CONST from '@src/CONST'; +import type {Policy, TaxRate, TaxRatesWithDefault} from '@src/types/onyx'; +import type {ApprovalRule, ExpenseRule} from '@src/types/onyx/Policy'; +import * as CurrencyUtils from './CurrencyUtils'; + +function formatDefaultTaxRateText(translate: LocaleContextProps['translate'], taxID: string, taxRate: TaxRate, policyTaxRates?: TaxRatesWithDefault) { + const taxRateText = `${taxRate.name} ${CONST.DOT_SEPARATOR} ${taxRate.value}`; + + if (!policyTaxRates) { + return taxRateText; + } + + const {defaultExternalID, foreignTaxDefault} = policyTaxRates; + let suffix; + + if (taxID === defaultExternalID && taxID === foreignTaxDefault) { + suffix = translate('common.default'); + } else if (taxID === defaultExternalID) { + suffix = translate('workspace.taxes.workspaceDefault'); + } else if (taxID === foreignTaxDefault) { + suffix = translate('workspace.taxes.foreignDefault'); + } + return `${taxRateText}${suffix ? ` ${CONST.DOT_SEPARATOR} ${suffix}` : ``}`; +} + +function formatRequireReceiptsOverText(translate: LocaleContextProps['translate'], policy: Policy, categoryMaxExpenseAmountNoReceipt?: number | null) { + const isAlwaysSelected = categoryMaxExpenseAmountNoReceipt === 0; + const isNeverSelected = categoryMaxExpenseAmountNoReceipt === CONST.DISABLED_MAX_EXPENSE_VALUE; + + if (isAlwaysSelected) { + return translate(`workspace.rules.categoryRules.requireReceiptsOverList.always`); + } + + if (isNeverSelected) { + return translate(`workspace.rules.categoryRules.requireReceiptsOverList.never`); + } + + const maxExpenseAmountToDisplay = policy?.maxExpenseAmount === CONST.DISABLED_MAX_EXPENSE_VALUE ? 0 : policy?.maxExpenseAmount; + + return translate( + `workspace.rules.categoryRules.requireReceiptsOverList.default`, + CurrencyUtils.convertToShortDisplayString(maxExpenseAmountToDisplay, policy?.outputCurrency ?? CONST.CURRENCY.USD), + ); +} + +function getCategoryApprover(approvalRules: ApprovalRule[], categoryName: string) { + return approvalRules?.find((rule) => rule.applyWhen.some((when) => when.value === categoryName))?.approver; +} + +function getCategoryDefaultTaxRate(expenseRules: ExpenseRule[], categoryName: string, defaultTaxRate?: string) { + const categoryDefaultTaxRate = expenseRules?.find((rule) => rule.applyWhen.some((when) => when.value === categoryName))?.tax?.field_id_TAX?.externalID; + + // If the default taxRate is not found in expenseRules, use the default value for policy + if (!categoryDefaultTaxRate) { + return defaultTaxRate; + } + + return categoryDefaultTaxRate; +} + +export {formatDefaultTaxRateText, formatRequireReceiptsOverText, getCategoryApprover, getCategoryDefaultTaxRate}; diff --git a/src/libs/DistanceRequestUtils.ts b/src/libs/DistanceRequestUtils.ts index b5af91dfacf..e9a2eaa8027 100644 --- a/src/libs/DistanceRequestUtils.ts +++ b/src/libs/DistanceRequestUtils.ts @@ -19,6 +19,7 @@ type MileageRate = { currency?: string; unit: Unit; name?: string; + enabled?: boolean; }; let lastSelectedDistanceRates: OnyxEntry = {}; @@ -32,7 +33,7 @@ Onyx.connect({ const METERS_TO_KM = 0.001; // 1 kilometer is 1000 meters const METERS_TO_MILES = 0.000621371; // There are approximately 0.000621371 miles in a meter -function getMileageRates(policy: OnyxInputOrEntry, includeDisabledRates = false): Record { +function getMileageRates(policy: OnyxInputOrEntry, includeDisabledRates = false, selectedRateID?: string): Record { const mileageRates: Record = {}; if (!policy?.customUnits) { @@ -45,7 +46,7 @@ function getMileageRates(policy: OnyxInputOrEntry, includeDisabledRates } Object.entries(distanceUnit.rates).forEach(([rateID, rate]) => { - if (!includeDisabledRates && rate.enabled === false) { + if (!includeDisabledRates && rate.enabled === false && (!selectedRateID || rateID !== selectedRateID)) { return; } @@ -55,6 +56,7 @@ function getMileageRates(policy: OnyxInputOrEntry, includeDisabledRates unit: distanceUnit.attributes.unit, name: rate.name, customUnitRateID: rate.customUnitRateID, + enabled: rate.enabled, }; }); diff --git a/src/libs/EmojiTrie.ts b/src/libs/EmojiTrie.ts index 88a1a7a275d..e33ba77eab2 100644 --- a/src/libs/EmojiTrie.ts +++ b/src/libs/EmojiTrie.ts @@ -14,8 +14,6 @@ type EmojiMetaData = { name?: string; }; -Timing.start(CONST.TIMING.TRIE_INITIALIZATION); - const supportedLanguages = [CONST.LOCALES.DEFAULT, CONST.LOCALES.ES] as const; type SupportedLanguage = TupleToUnion; @@ -123,6 +121,7 @@ const emojiTrie: EmojiTrie = supportedLanguages.reduce((acc, lang) => { }, {} as EmojiTrie); const buildEmojisTrie = (locale: Locale) => { + Timing.start(CONST.TIMING.TRIE_INITIALIZATION); // Normalize the locale to lowercase and take the first part before any dash const normalizedLocale = locale.toLowerCase().split('-')[0]; const localeToUse = supportedLanguages.includes(normalizedLocale as SupportedLanguage) ? (normalizedLocale as SupportedLanguage) : undefined; @@ -131,10 +130,9 @@ const buildEmojisTrie = (locale: Locale) => { return; // Return early if the locale is not supported or the trie is already built } emojiTrie[localeToUse] = createTrie(localeToUse); + Timing.end(CONST.TIMING.TRIE_INITIALIZATION); }; -Timing.end(CONST.TIMING.TRIE_INITIALIZATION); - export default emojiTrie; export {buildEmojisTrie}; diff --git a/src/libs/Firebase/index.native.ts b/src/libs/Firebase/index.native.ts index d2746d8b25e..0af52eefb58 100644 --- a/src/libs/Firebase/index.native.ts +++ b/src/libs/Firebase/index.native.ts @@ -41,7 +41,7 @@ const stopTrace: StopTrace = (customEventName) => { return; } - const trace = traceMap[customEventName].trace; + const trace = traceMap[customEventName]?.trace; if (!trace) { return; } diff --git a/src/libs/HttpUtils.ts b/src/libs/HttpUtils.ts index a8ef8a90dff..550a75c3d36 100644 --- a/src/libs/HttpUtils.ts +++ b/src/libs/HttpUtils.ts @@ -37,9 +37,6 @@ const abortControllerMap = new Map(); abortControllerMap.set(ABORT_COMMANDS.All, new AbortController()); abortControllerMap.set(ABORT_COMMANDS.SearchForReports, new AbortController()); -// Some existing old commands (6+ years) exempted from the auth writes count check -const exemptedCommandsWithAuthWrites: string[] = ['SetWorkspaceAutoReportingFrequency']; - /** * The API commands that require the skew calculation */ @@ -133,7 +130,7 @@ function processHTTPRequest(url: string, method: RequestType = 'get', body: Form }); } - if (response.jsonCode === CONST.JSON_CODE.MANY_WRITES_ERROR && !exemptedCommandsWithAuthWrites.includes(response.data?.phpCommandName ?? '')) { + if (response.jsonCode === CONST.JSON_CODE.MANY_WRITES_ERROR) { if (response.data) { const {phpCommandName, authWriteCommands} = response.data; // eslint-disable-next-line max-len diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index 1b2390b17c3..a8c1c985ba2 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -18,7 +18,6 @@ import type { ReportDescriptionNavigatorParamList, ReportDetailsNavigatorParamList, ReportSettingsNavigatorParamList, - RoomInviteNavigatorParamList, RoomMembersNavigatorParamList, SearchAdvancedFiltersParamList, SearchReportParamList, @@ -149,11 +148,9 @@ const ReportParticipantsModalStackNavigator = createModalStackNavigator({ - [SCREENS.ROOM_MEMBERS_ROOT]: () => require('../../../../pages/RoomMembersPage').default, -}); - -const RoomInviteModalStackNavigator = createModalStackNavigator({ - [SCREENS.ROOM_INVITE_ROOT]: () => require('../../../../pages/RoomInvitePage').default, + [SCREENS.ROOM_MEMBERS.ROOT]: () => require('../../../../pages/RoomMembersPage').default, + [SCREENS.ROOM_MEMBERS.INVITE]: () => require('../../../../pages/RoomInvitePage').default, + [SCREENS.ROOM_MEMBERS.DETAILS]: () => require('../../../../pages/RoomMemberDetailsPage').default, }); const NewChatModalStackNavigator = createModalStackNavigator({ @@ -250,6 +247,11 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../../pages/workspace/categories/EditCategoryPage').default, [SCREENS.WORKSPACE.CATEGORY_PAYROLL_CODE]: () => require('../../../../pages/workspace/categories/CategoryPayrollCodePage').default, [SCREENS.WORKSPACE.CATEGORY_GL_CODE]: () => require('../../../../pages/workspace/categories/CategoryGLCodePage').default, + [SCREENS.WORKSPACE.CATEGORY_DEFAULT_TAX_RATE]: () => require('../../../../pages/workspace/categories/CategoryDefaultTaxRatePage').default, + [SCREENS.WORKSPACE.CATEGORY_FLAG_AMOUNTS_OVER]: () => require('../../../../pages/workspace/categories/CategoryFlagAmountsOverPage').default, + [SCREENS.WORKSPACE.CATEGORY_DESCRIPTION_HINT]: () => require('../../../../pages/workspace/categories/CategoryDescriptionHintPage').default, + [SCREENS.WORKSPACE.CATEGORY_REQUIRE_RECEIPTS_OVER]: () => require('../../../../pages/workspace/categories/CategoryRequireReceiptsOverPage').default, + [SCREENS.WORKSPACE.CATEGORY_APPROVER]: () => require('../../../../pages/workspace/categories/CategoryApproverPage').default, [SCREENS.WORKSPACE.CREATE_DISTANCE_RATE]: () => require('../../../../pages/workspace/distanceRates/CreateDistanceRatePage').default, [SCREENS.WORKSPACE.DISTANCE_RATES_SETTINGS]: () => require('../../../../pages/workspace/distanceRates/PolicyDistanceRatesSettingsPage').default, [SCREENS.WORKSPACE.DISTANCE_RATE_DETAILS]: () => require('../../../../pages/workspace/distanceRates/PolicyDistanceRateDetailsPage').default, @@ -571,7 +573,6 @@ export { ReportDetailsModalStackNavigator, ReportParticipantsModalStackNavigator, ReportSettingsModalStackNavigator, - RoomInviteModalStackNavigator, RoomMembersModalStackNavigator, SettingsModalStackNavigator, SignInModalStackNavigator, diff --git a/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx index fb012139f9d..444a0d927cc 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx @@ -107,10 +107,6 @@ function RightModalNavigator({navigation, route}: RightModalNavigatorProps) { name={SCREENS.RIGHT_MODAL.ROOM_MEMBERS} component={ModalStackNavigators.RoomMembersModalStackNavigator} /> - > = { SCREENS.WORKSPACE.CATEGORY_EDIT, SCREENS.WORKSPACE.CATEGORY_GL_CODE, SCREENS.WORKSPACE.CATEGORY_PAYROLL_CODE, + SCREENS.WORKSPACE.CATEGORY_DEFAULT_TAX_RATE, + SCREENS.WORKSPACE.CATEGORY_FLAG_AMOUNTS_OVER, + SCREENS.WORKSPACE.CATEGORY_DESCRIPTION_HINT, + SCREENS.WORKSPACE.CATEGORY_APPROVER, + SCREENS.WORKSPACE.CATEGORY_REQUIRE_RECEIPTS_OVER, ], [SCREENS.WORKSPACE.DISTANCE_RATES]: [ SCREENS.WORKSPACE.CREATE_DISTANCE_RATE, diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts index 65fb05f8d00..03026379a3e 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -608,6 +608,36 @@ const config: LinkingOptions['config'] = { categoryName: (categoryName: string) => decodeURIComponent(categoryName), }, }, + [SCREENS.WORKSPACE.CATEGORY_DEFAULT_TAX_RATE]: { + path: ROUTES.WORSKPACE_CATEGORY_DEFAULT_TAX_RATE.route, + parse: { + categoryName: (categoryName: string) => decodeURIComponent(categoryName), + }, + }, + [SCREENS.WORKSPACE.CATEGORY_FLAG_AMOUNTS_OVER]: { + path: ROUTES.WORSKPACE_CATEGORY_FLAG_AMOUNTS_OVER.route, + parse: { + categoryName: (categoryName: string) => decodeURIComponent(categoryName), + }, + }, + [SCREENS.WORKSPACE.CATEGORY_DESCRIPTION_HINT]: { + path: ROUTES.WORSKPACE_CATEGORY_DESCRIPTION_HINT.route, + parse: { + categoryName: (categoryName: string) => decodeURIComponent(categoryName), + }, + }, + [SCREENS.WORKSPACE.CATEGORY_APPROVER]: { + path: ROUTES.WORSKPACE_CATEGORY_APPROVER.route, + parse: { + categoryName: (categoryName: string) => decodeURIComponent(categoryName), + }, + }, + [SCREENS.WORKSPACE.CATEGORY_REQUIRE_RECEIPTS_OVER]: { + path: ROUTES.WORSKPACE_CATEGORY_REQUIRE_RECEIPTS_OVER.route, + parse: { + categoryName: (categoryName: string) => decodeURIComponent(categoryName), + }, + }, [SCREENS.WORKSPACE.CREATE_DISTANCE_RATE]: { path: ROUTES.WORKSPACE_CREATE_DISTANCE_RATE.route, }, @@ -904,14 +934,11 @@ const config: LinkingOptions['config'] = { [SCREENS.REPORT_PARTICIPANTS.ROLE]: ROUTES.REPORT_PARTICIPANTS_ROLE_SELECTION.route, }, }, - [SCREENS.RIGHT_MODAL.ROOM_INVITE]: { - screens: { - [SCREENS.ROOM_INVITE_ROOT]: ROUTES.ROOM_INVITE.route, - }, - }, [SCREENS.RIGHT_MODAL.ROOM_MEMBERS]: { screens: { - [SCREENS.ROOM_MEMBERS_ROOT]: ROUTES.ROOM_MEMBERS.route, + [SCREENS.ROOM_MEMBERS.ROOT]: ROUTES.ROOM_MEMBERS.route, + [SCREENS.ROOM_MEMBERS.INVITE]: ROUTES.ROOM_INVITE.route, + [SCREENS.ROOM_MEMBERS.DETAILS]: ROUTES.ROOM_MEMBER_DETAILS.route, }, }, [SCREENS.RIGHT_MODAL.MONEY_REQUEST]: { diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index ee46cbd238e..c4e7492d783 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -213,6 +213,26 @@ type SettingsNavigatorParamList = { policyID: string; categoryName: string; }; + [SCREENS.WORKSPACE.CATEGORY_DEFAULT_TAX_RATE]: { + policyID: string; + categoryName: string; + }; + [SCREENS.WORKSPACE.CATEGORY_FLAG_AMOUNTS_OVER]: { + policyID: string; + categoryName: string; + }; + [SCREENS.WORKSPACE.CATEGORY_DESCRIPTION_HINT]: { + policyID: string; + categoryName: string; + }; + [SCREENS.WORKSPACE.CATEGORY_APPROVER]: { + policyID: string; + categoryName: string; + }; + [SCREENS.WORKSPACE.CATEGORY_REQUIRE_RECEIPTS_OVER]: { + policyID: string; + categoryName: string; + }; [SCREENS.WORKSPACE.CATEGORY_SETTINGS]: { policyID: string; categoryName: string; @@ -800,14 +820,15 @@ type ParticipantsNavigatorParamList = { }; type RoomMembersNavigatorParamList = { - [SCREENS.ROOM_MEMBERS_ROOT]: undefined; -}; - -type RoomInviteNavigatorParamList = { - [SCREENS.ROOM_INVITE_ROOT]: { + [SCREENS.ROOM_MEMBERS.ROOT]: {reportID: string}; + [SCREENS.ROOM_MEMBERS.INVITE]: { reportID: string; role?: 'accountant'; }; + [SCREENS.ROOM_MEMBERS.DETAILS]: { + reportID: string; + accountID: string; + }; }; type MoneyRequestNavigatorParamList = { @@ -1112,7 +1133,6 @@ type RightModalNavigatorParamList = { [SCREENS.RIGHT_MODAL.REPORT_DESCRIPTION]: NavigatorScreenParams; [SCREENS.RIGHT_MODAL.PARTICIPANTS]: NavigatorScreenParams; [SCREENS.RIGHT_MODAL.ROOM_MEMBERS]: NavigatorScreenParams; - [SCREENS.RIGHT_MODAL.ROOM_INVITE]: NavigatorScreenParams; [SCREENS.RIGHT_MODAL.MONEY_REQUEST]: NavigatorScreenParams; [SCREENS.RIGHT_MODAL.NEW_TASK]: NavigatorScreenParams; [SCREENS.RIGHT_MODAL.TEACHERS_UNITE]: NavigatorScreenParams; @@ -1176,7 +1196,7 @@ type FullScreenNavigatorParamList = { }; [SCREENS.WORKSPACE.WORKFLOWS_APPROVALS_APPROVER]: { policyID: string; - approverIndex?: number; + approverIndex: number; backTo?: Routes; }; [SCREENS.WORKSPACE.WORKFLOWS_AUTO_REPORTING_FREQUENCY]: { @@ -1410,7 +1430,6 @@ export type { ReportDetailsNavigatorParamList, ReportSettingsNavigatorParamList, RightModalNavigatorParamList, - RoomInviteNavigatorParamList, RoomMembersNavigatorParamList, RootStackParamList, SettingsNavigatorParamList, diff --git a/src/libs/Permissions.ts b/src/libs/Permissions.ts index 0e67dc27051..8c47100e465 100644 --- a/src/libs/Permissions.ts +++ b/src/libs/Permissions.ts @@ -20,10 +20,6 @@ function canUseP2PDistanceRequests(betas: OnyxEntry, iouType: IOUType | return !!betas?.includes(CONST.BETAS.P2P_DISTANCE_REQUESTS) || canUseAllBetas(betas) || iouType === CONST.IOU.TYPE.TRACK; } -function canUseWorkflowsAdvancedApproval(betas: OnyxEntry): boolean { - return !!betas?.includes(CONST.BETAS.WORKFLOWS_ADVANCED_APPROVAL) || canUseAllBetas(betas); -} - function canUseSpotnanaTravel(betas: OnyxEntry): boolean { return !!betas?.includes(CONST.BETAS.SPOTNANA_TRAVEL) || canUseAllBetas(betas); } @@ -65,7 +61,6 @@ export default { canUseLinkPreviews, canUseDupeDetection, canUseP2PDistanceRequests, - canUseWorkflowsAdvancedApproval, canUseSpotnanaTravel, canUseWorkspaceFeeds, canUseCompanyCardFeeds, diff --git a/src/libs/ReceiptUtils.ts b/src/libs/ReceiptUtils.ts index b42a23e215b..49c275278c4 100644 --- a/src/libs/ReceiptUtils.ts +++ b/src/libs/ReceiptUtils.ts @@ -36,7 +36,7 @@ function getThumbnailAndImageURIs(transaction: OnyxEntry, receiptPa // filename of uploaded image or last part of remote URI const filename = errors?.filename ?? transaction?.filename ?? receiptFileName ?? ''; const isReceiptImage = Str.isImage(filename); - const hasEReceipt = transaction?.hasEReceipt; + const hasEReceipt = !TransactionUtils.hasReceiptSource(transaction) && transaction?.hasEReceipt; const isReceiptPDF = Str.isPDF(filename); if (hasEReceipt) { diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index e65f88032cf..4fdc45a8c0f 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -5499,7 +5499,7 @@ function buildOptimisticWorkspaceChats(policyID: string, policyName: string, exp false, policyName, undefined, - undefined, + CONST.REPORT.WRITE_CAPABILITIES.ADMINS, CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS, ); const announceChatReportID = announceChatData.reportID; @@ -5875,7 +5875,7 @@ function doesTransactionThreadHaveViolations( return ( TransactionUtils.hasViolation(IOUTransactionID, transactionViolations) || TransactionUtils.hasWarningTypeViolation(IOUTransactionID, transactionViolations) || - TransactionUtils.hasModifiedAmountOrDateViolation(IOUTransactionID, transactionViolations) + (isPaidGroupPolicy(report) && TransactionUtils.hasModifiedAmountOrDateViolation(IOUTransactionID, transactionViolations)) ); } diff --git a/src/libs/SearchUtils.ts b/src/libs/SearchUtils.ts index 1b1fcaee868..549cdfc3b52 100644 --- a/src/libs/SearchUtils.ts +++ b/src/libs/SearchUtils.ts @@ -18,6 +18,7 @@ import DateUtils from './DateUtils'; import {translateLocal} from './Localize'; import navigationRef from './Navigation/navigationRef'; import type {AuthScreensParamList, RootStackParamList, State} from './Navigation/types'; +import * as ReportActionsUtils from './ReportActionsUtils'; import * as searchParser from './SearchParser/searchParser'; import * as TransactionUtils from './TransactionUtils'; import * as UserUtils from './UserUtils'; @@ -200,6 +201,10 @@ function getReportActionsSections(data: OnyxTypes.SearchResults['data']): Report const reportActions = data[key]; for (const reportAction of Object.values(reportActions)) { const from = data.personalDetailsList?.[reportAction.accountID]; + if (ReportActionsUtils.isDeletedAction(reportAction)) { + // eslint-disable-next-line no-continue + continue; + } reportActionItems.push({ ...reportAction, from, @@ -307,7 +312,7 @@ function getSections(type: SearchDataTypes, status: SearchStatus, data: OnyxType function getSortedSections(type: SearchDataTypes, status: SearchStatus, data: ListItemDataType, sortBy?: SearchColumnType, sortOrder?: SortOrder) { if (type === CONST.SEARCH.DATA_TYPES.CHAT) { - return data; + return getSortedReportActionData(data as ReportActionListItemType[]); } return status === CONST.SEARCH.STATUS.EXPENSE.ALL ? getSortedTransactionData(data as TransactionListItemType[], sortBy, sortOrder) : getSortedReportData(data as ReportListItemType[]); } @@ -356,6 +361,19 @@ function getSortedReportData(data: ReportListItemType[]) { }); } +function getSortedReportActionData(data: ReportActionListItemType[]) { + return data.sort((a, b) => { + const aValue = a?.created; + const bValue = b?.created; + + if (aValue === undefined || bValue === undefined) { + return 0; + } + + return bValue.toLowerCase().localeCompare(aValue); + }); +} + function getCurrentSearchParams() { const rootState = navigationRef.getRootState() as State; diff --git a/src/libs/SidebarUtils.ts b/src/libs/SidebarUtils.ts index 8affc09700f..4bd7e2714e2 100644 --- a/src/libs/SidebarUtils.ts +++ b/src/libs/SidebarUtils.ts @@ -240,6 +240,7 @@ function getOptionData({ policy, parentReportAction, hasViolations, + lastMessageTextFromReport: lastMessageTextFromReportProp, transactionViolations, invoiceReceiverPolicy, }: { @@ -250,6 +251,7 @@ function getOptionData({ policy: OnyxEntry | undefined; parentReportAction: OnyxEntry | undefined; hasViolations: boolean; + lastMessageTextFromReport?: string; invoiceReceiverPolicy?: OnyxEntry; transactionViolations?: OnyxCollection; }): ReportUtils.OptionData | undefined { @@ -384,7 +386,11 @@ function getOptionData({ } const lastActorDisplayName = OptionsListUtils.getLastActorDisplayName(lastActorDetails, hasMultipleParticipants); - const lastMessageTextFromReport = OptionsListUtils.getLastMessageTextForReport(report, lastActorDetails, policy); + + let lastMessageTextFromReport = lastMessageTextFromReportProp; + if (!lastMessageTextFromReport) { + lastMessageTextFromReport = OptionsListUtils.getLastMessageTextForReport(report, lastActorDetails, policy); + } // We need to remove sms domain in case the last message text has a phone number mention with sms domain. let lastMessageText = Str.removeSMSDomain(lastMessageTextFromReport); diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts index 339f57c8e04..083a8d23815 100644 --- a/src/libs/TransactionUtils/index.ts +++ b/src/libs/TransactionUtils/index.ts @@ -184,6 +184,11 @@ function hasReceipt(transaction: OnyxInputOrEntry | undefined): boo return !!transaction?.receipt?.state || hasEReceipt(transaction); } +/** Check if the receipt has the source file */ +function hasReceiptSource(transaction: OnyxInputOrEntry): boolean { + return !!transaction?.receipt?.source; +} + function isMerchantMissing(transaction: OnyxEntry) { if (transaction?.modifiedMerchant && transaction.modifiedMerchant !== '') { return transaction.modifiedMerchant === CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT; @@ -1136,6 +1141,7 @@ export { isPayAtEndExpense, removeSettledAndApprovedTransactions, getCardName, + hasReceiptSource, }; export type {TransactionChanges}; diff --git a/src/libs/WorkflowUtils.ts b/src/libs/WorkflowUtils.ts index 4682654d358..7d936bff0b3 100644 --- a/src/libs/WorkflowUtils.ts +++ b/src/libs/WorkflowUtils.ts @@ -200,7 +200,10 @@ type ConvertApprovalWorkflowToPolicyEmployeesParams = { type: ValueOf; }; -/** Convert an approval workflow to a list of policy employees */ +/** + * This function converts an approval workflow into a list of policy employees. + * An optimized list is created that contains only the updated employees to maintain minimal data changes. + */ function convertApprovalWorkflowToPolicyEmployees({ approvalWorkflow, previousEmployeeList, @@ -221,6 +224,8 @@ function convertApprovalWorkflowToPolicyEmployees({ const nextApprover = approvalWorkflow.approvers.at(index + 1); const forwardsTo = type === CONST.APPROVAL_WORKFLOW.TYPE.REMOVE ? '' : nextApprover?.email ?? ''; + // For every approver, we check if the forwardsTo field has changed. + // If it has, we update the employee list with the new forwardsTo value. if (previousEmployeeList[approver.email]?.forwardsTo === forwardsTo) { return; } @@ -235,6 +240,8 @@ function convertApprovalWorkflowToPolicyEmployees({ approvalWorkflow.members.forEach(({email}) => { const submitsTo = type === CONST.APPROVAL_WORKFLOW.TYPE.REMOVE ? '' : firstApprover.email ?? ''; + // For every member, we check if the submitsTo field has changed. + // If it has, we update the employee list with the new submitsTo value. if (previousEmployeeList[email]?.submitsTo === submitsTo) { return; } @@ -246,6 +253,8 @@ function convertApprovalWorkflowToPolicyEmployees({ }; }); + // For each member to remove, we update the employee list with submitsTo set to '' + // which will set the submitsTo field to the default approver email on backend. membersToRemove?.forEach(({email}) => { updatedEmployeeList[email] = { ...(updatedEmployeeList[email] ? updatedEmployeeList[email] : {email}), @@ -254,6 +263,8 @@ function convertApprovalWorkflowToPolicyEmployees({ }; }); + // For each approver to remove, we update the employee list with forwardsTo set to '' + // which will reset the forwardsTo on the backend. approversToRemove?.forEach(({email}) => { updatedEmployeeList[email] = { ...(updatedEmployeeList[email] ? updatedEmployeeList[email] : {email}), diff --git a/src/libs/actions/Policy/Category.ts b/src/libs/actions/Policy/Category.ts index 4be9c8d37d6..501b7cbbe1e 100644 --- a/src/libs/actions/Policy/Category.ts +++ b/src/libs/actions/Policy/Category.ts @@ -1,20 +1,38 @@ +import lodashCloneDeep from 'lodash/cloneDeep'; import lodashUnion from 'lodash/union'; import type {NullishDeep, OnyxCollection, OnyxUpdate} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; import * as API from '@libs/API'; -import type {EnablePolicyCategoriesParams, OpenPolicyCategoriesPageParams, SetPolicyDistanceRatesDefaultCategoryParams, UpdatePolicyCategoryGLCodeParams} from '@libs/API/parameters'; +import type { + EnablePolicyCategoriesParams, + OpenPolicyCategoriesPageParams, + RemovePolicyCategoryReceiptsRequiredParams, + SetPolicyCategoryApproverParams, + SetPolicyCategoryDescriptionRequiredParams, + SetPolicyCategoryMaxAmountParams, + SetPolicyCategoryReceiptsRequiredParams, + SetPolicyCategoryTaxParams, + SetPolicyDistanceRatesDefaultCategoryParams, + SetWorkspaceCategoryDescriptionHintParams, + UpdatePolicyCategoryGLCodeParams, +} from '@libs/API/parameters'; import {READ_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; +import * as ApiUtils from '@libs/ApiUtils'; +import * as CurrencyUtils from '@libs/CurrencyUtils'; import * as ErrorUtils from '@libs/ErrorUtils'; +import fileDownload from '@libs/fileDownload'; import getIsNarrowLayout from '@libs/getIsNarrowLayout'; import {translateLocal} from '@libs/Localize'; import Log from '@libs/Log'; +import enhanceParameters from '@libs/Network/enhanceParameters'; import * as OptionsListUtils from '@libs/OptionsListUtils'; import {navigateWhenEnableFeature, removePendingFieldsFromCustomUnit} from '@libs/PolicyUtils'; import * as ReportUtils from '@libs/ReportUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Policy, PolicyCategories, PolicyCategory, RecentlyUsedCategories, Report} from '@src/types/onyx'; -import type {CustomUnit} from '@src/types/onyx/Policy'; +import type {ApprovalRule, CustomUnit, ExpenseRule} from '@src/types/onyx/Policy'; +import type {PolicyCategoryExpenseLimitType} from '@src/types/onyx/PolicyCategory'; import type {OnyxData} from '@src/types/onyx/Request'; const allPolicies: OnyxCollection = {}; @@ -287,6 +305,196 @@ function setWorkspaceCategoryEnabled(policyID: string, categoriesToUpdate: Recor API.write(WRITE_COMMANDS.SET_WORKSPACE_CATEGORIES_ENABLED, parameters, onyxData); } +function setPolicyCategoryDescriptionRequired(policyID: string, categoryName: string, areCommentsRequired: boolean) { + const policyCategoryToUpdate = allPolicyCategories?.[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`]?.[categoryName]; + const originalAreCommentsRequired = policyCategoryToUpdate?.areCommentsRequired; + const originalCommentHint = policyCategoryToUpdate?.commentHint; + + // When areCommentsRequired is set to false, commentHint has to be reset + const updatedCommentHint = areCommentsRequired ? allPolicyCategories?.[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`]?.[categoryName]?.commentHint : ''; + + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, + value: { + [categoryName]: { + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + pendingFields: { + areCommentsRequired: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + areCommentsRequired, + commentHint: updatedCommentHint, + }, + }, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, + value: { + [categoryName]: { + pendingAction: null, + pendingFields: { + areCommentsRequired: null, + }, + areCommentsRequired, + commentHint: updatedCommentHint, + }, + }, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, + value: { + [categoryName]: { + errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'), + pendingAction: null, + pendingFields: { + areCommentsRequired: null, + }, + areCommentsRequired: originalAreCommentsRequired, + commentHint: originalCommentHint, + }, + }, + }, + ], + }; + + const parameters: SetPolicyCategoryDescriptionRequiredParams = { + policyID, + categoryName, + areCommentsRequired, + }; + + API.write(WRITE_COMMANDS.SET_POLICY_CATEGORY_DESCRIPTION_REQUIRED, parameters, onyxData); +} + +function setPolicyCategoryReceiptsRequired(policyID: string, categoryName: string, maxExpenseAmountNoReceipt: number) { + const originalMaxExpenseAmountNoReceipt = allPolicyCategories?.[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`]?.[categoryName]?.maxExpenseAmountNoReceipt; + + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, + value: { + [categoryName]: { + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + pendingFields: { + maxExpenseAmountNoReceipt: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + maxExpenseAmountNoReceipt, + }, + }, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, + value: { + [categoryName]: { + pendingAction: null, + pendingFields: { + maxExpenseAmountNoReceipt: null, + }, + maxExpenseAmountNoReceipt, + }, + }, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, + value: { + [categoryName]: { + errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'), + pendingAction: null, + pendingFields: { + maxExpenseAmountNoReceipt: null, + }, + maxExpenseAmountNoReceipt: originalMaxExpenseAmountNoReceipt, + }, + }, + }, + ], + }; + + const parameters: SetPolicyCategoryReceiptsRequiredParams = { + policyID, + categoryName, + maxExpenseAmountNoReceipt, + }; + + API.write(WRITE_COMMANDS.SET_POLICY_CATEGORY_RECEIPTS_REQUIRED, parameters, onyxData); +} + +function removePolicyCategoryReceiptsRequired(policyID: string, categoryName: string) { + const originalMaxExpenseAmountNoReceipt = allPolicyCategories?.[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`]?.[categoryName]?.maxExpenseAmountNoReceipt; + + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, + value: { + [categoryName]: { + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + pendingFields: { + maxExpenseAmountNoReceipt: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + maxExpenseAmountNoReceipt: null, + }, + }, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, + value: { + [categoryName]: { + pendingAction: null, + pendingFields: { + maxExpenseAmountNoReceipt: null, + }, + maxExpenseAmountNoReceipt: null, + }, + }, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, + value: { + [categoryName]: { + errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'), + pendingAction: null, + pendingFields: { + maxExpenseAmountNoReceipt: null, + }, + maxExpenseAmountNoReceipt: originalMaxExpenseAmountNoReceipt, + }, + }, + }, + ], + }; + + const parameters: RemovePolicyCategoryReceiptsRequiredParams = { + policyID, + categoryName, + }; + + API.write(WRITE_COMMANDS.REMOVE_POLICY_CATEGORY_RECEIPTS_REQUIRED, parameters, onyxData); +} + function createPolicyCategory(policyID: string, categoryName: string) { const onyxData = buildOptimisticPolicyCategories(policyID, [categoryName]); @@ -307,7 +515,7 @@ function importPolicyCategories(policyID: string, categories: PolicyCategory[]) categories: JSON.stringify([...categories.map((category) => ({name: category.name, enabled: category.enabled, 'GL Code': String(category['GL Code'])}))]), }; - API.write(WRITE_COMMANDS.IMPORT_CATEGORIES_SREADSHEET, parameters, onyxData); + API.write(WRITE_COMMANDS.IMPORT_CATEGORIES_SPREADSHEET, parameters, onyxData); } function renamePolicyCategory(policyID: string, policyCategory: {oldName: string; newName: string}) { @@ -793,10 +1001,316 @@ function setPolicyDistanceRatesDefaultCategory(policyID: string, currentCustomUn API.write(WRITE_COMMANDS.SET_POLICY_DISTANCE_RATES_DEFAULT_CATEGORY, params, {optimisticData, successData, failureData}); } +function downloadCategoriesCSV(policyID: string) { + const finalParameters = enhanceParameters(WRITE_COMMANDS.EXPORT_CATEGORIES_CSV, { + policyID, + }); + + const formData = new FormData(); + Object.entries(finalParameters).forEach(([key, value]) => { + formData.append(key, String(value)); + }); + + fileDownload(ApiUtils.getCommandURL({command: WRITE_COMMANDS.EXPORT_CATEGORIES_CSV}), 'Categories.csv', '', false, formData, CONST.NETWORK.METHOD.POST); +} + +function setWorkspaceCategoryDescriptionHint(policyID: string, categoryName: string, commentHint: string) { + const originalCommentHint = allPolicyCategories?.[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`]?.[categoryName]?.commentHint; + + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, + value: { + [categoryName]: { + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + pendingFields: { + commentHint: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + commentHint, + }, + }, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, + value: { + [categoryName]: { + pendingAction: null, + pendingFields: { + commentHint: null, + }, + commentHint, + }, + }, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, + value: { + [categoryName]: { + errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'), + pendingAction: null, + pendingFields: { + commentHint: null, + }, + commentHint: originalCommentHint, + }, + }, + }, + ], + }; + + const parameters: SetWorkspaceCategoryDescriptionHintParams = { + policyID, + categoryName, + commentHint, + }; + + API.write(WRITE_COMMANDS.SET_WORKSPACE_CATEGORY_DESCRIPTION_HINT, parameters, onyxData); +} + +function setPolicyCategoryMaxAmount(policyID: string, categoryName: string, maxExpenseAmount: string, expenseLimitType: PolicyCategoryExpenseLimitType) { + const policyCategoryToUpdate = allPolicyCategories?.[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`]?.[categoryName]; + const originalMaxExpenseAmount = policyCategoryToUpdate?.maxExpenseAmount; + const originalExpenseLimitType = policyCategoryToUpdate?.expenseLimitType; + const parsedMaxExpenseAmount = maxExpenseAmount === '' ? null : CurrencyUtils.convertToBackendAmount(parseFloat(maxExpenseAmount)); + + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, + value: { + [categoryName]: { + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + pendingFields: { + maxExpenseAmount: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + expenseLimitType: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + maxExpenseAmount: parsedMaxExpenseAmount, + expenseLimitType, + }, + }, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, + value: { + [categoryName]: { + pendingAction: null, + pendingFields: { + maxExpenseAmount: null, + expenseLimitType: null, + }, + maxExpenseAmount: parsedMaxExpenseAmount, + expenseLimitType, + }, + }, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, + value: { + [categoryName]: { + errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'), + pendingAction: null, + pendingFields: { + maxExpenseAmount: null, + expenseLimitType: null, + }, + maxExpenseAmount: originalMaxExpenseAmount, + expenseLimitType: originalExpenseLimitType, + }, + }, + }, + ], + }; + + const parameters: SetPolicyCategoryMaxAmountParams = { + policyID, + categoryName, + maxExpenseAmount: parsedMaxExpenseAmount, + expenseLimitType, + }; + + API.write(WRITE_COMMANDS.SET_POLICY_CATEGORY_MAX_AMOUNT, parameters, onyxData); +} + +function setPolicyCategoryApprover(policyID: string, categoryName: string, approver: string) { + const policy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]; + const approvalRules = policy?.rules?.approvalRules ?? []; + let updatedApprovalRules: ApprovalRule[] = lodashCloneDeep(approvalRules); + const existingCategoryApproverRule = updatedApprovalRules.find((rule) => rule.applyWhen.some((when) => when.value === categoryName)); + let newApprover = approver; + + if (!existingCategoryApproverRule) { + updatedApprovalRules.push({ + approver, + applyWhen: [ + { + condition: 'matches', + field: 'category', + value: categoryName, + }, + ], + }); + } else if (existingCategoryApproverRule?.approver === approver) { + updatedApprovalRules = updatedApprovalRules.filter((rule) => rule.approver !== approver); + newApprover = ''; + } else { + const indexToUpdate = updatedApprovalRules.indexOf(existingCategoryApproverRule); + updatedApprovalRules[indexToUpdate].approver = approver; + } + + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + rules: { + approvalRules: updatedApprovalRules, + pendingFields: { + approvalRules: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + }, + }, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + rules: { + pendingFields: { + approvalRules: null, + }, + }, + }, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + rules: { + approvalRules, + pendingFields: { + approvalRules: null, + }, + }, + }, + }, + ], + }; + + const parameters: SetPolicyCategoryApproverParams = { + policyID, + categoryName, + approver: newApprover, + }; + + API.write(WRITE_COMMANDS.SET_POLICY_CATEGORY_APPROVER, parameters, onyxData); +} + +function setPolicyCategoryTax(policyID: string, categoryName: string, taxID: string) { + const policy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]; + const expenseRules = policy?.rules?.expenseRules ?? []; + const updatedExpenseRules: ExpenseRule[] = lodashCloneDeep(expenseRules); + const existingCategoryExpenseRule = updatedExpenseRules.find((rule) => rule.applyWhen.some((when) => when.value === categoryName)); + + if (!existingCategoryExpenseRule) { + updatedExpenseRules.push({ + tax: { + // eslint-disable-next-line @typescript-eslint/naming-convention + field_id_TAX: { + externalID: taxID, + }, + }, + applyWhen: [ + { + condition: 'matches', + field: 'category', + value: categoryName, + }, + ], + }); + } else { + const indexToUpdate = updatedExpenseRules.indexOf(existingCategoryExpenseRule); + updatedExpenseRules[indexToUpdate].tax.field_id_TAX.externalID = taxID; + } + + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + rules: { + expenseRules: updatedExpenseRules, + pendingFields: { + expenseRules: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + }, + }, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + rules: { + pendingFields: { + expenseRules: null, + }, + }, + }, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + rules: { + expenseRules, + pendingFields: { + expenseRules: null, + }, + }, + }, + }, + ], + }; + + const parameters: SetPolicyCategoryTaxParams = { + policyID, + categoryName, + taxID, + }; + + API.write(WRITE_COMMANDS.SET_POLICY_CATEGORY_TAX, parameters, onyxData); +} + export { openPolicyCategoriesPage, buildOptimisticPolicyRecentlyUsedCategories, setWorkspaceCategoryEnabled, + setPolicyCategoryDescriptionRequired, + setWorkspaceCategoryDescriptionHint, setWorkspaceRequiresCategory, setPolicyCategoryPayrollCode, createPolicyCategory, @@ -807,5 +1321,11 @@ export { setPolicyDistanceRatesDefaultCategory, deleteWorkspaceCategories, buildOptimisticPolicyCategories, + setPolicyCategoryReceiptsRequired, + removePolicyCategoryReceiptsRequired, + setPolicyCategoryMaxAmount, + setPolicyCategoryApprover, + setPolicyCategoryTax, importPolicyCategories, + downloadCategoriesCSV, }; diff --git a/src/libs/actions/Policy/Policy.ts b/src/libs/actions/Policy/Policy.ts index a61306616b1..7bb66d02d79 100644 --- a/src/libs/actions/Policy/Policy.ts +++ b/src/libs/actions/Policy/Policy.ts @@ -1002,6 +1002,7 @@ function updateWorkspaceAvatar(policyID: string, file: File) { * Deletes the avatar image for the workspace */ function deleteWorkspaceAvatar(policyID: string) { + const policy = getPolicy(policyID); const optimisticData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, @@ -1014,6 +1015,7 @@ function deleteWorkspaceAvatar(policyID: string) { avatarURL: null, }, avatarURL: '', + originalFileName: null, }, }, ]; @@ -1033,6 +1035,8 @@ function deleteWorkspaceAvatar(policyID: string) { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, value: { + avatarURL: policy?.avatarURL, + originalFileName: policy?.originalFileName, errorFields: { avatarURL: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('avatarWithImagePicker.deleteWorkspaceError'), }, diff --git a/src/libs/actions/Session/index.ts b/src/libs/actions/Session/index.ts index 3e057a4745a..ab209e9bf92 100644 --- a/src/libs/actions/Session/index.ts +++ b/src/libs/actions/Session/index.ts @@ -482,7 +482,7 @@ function signUpUser() { function signInAfterTransitionFromOldDot(transitionURL: string) { const [route, queryParams] = transitionURL.split('?'); - const {email, authToken, accountID, autoGeneratedLogin, autoGeneratedPassword, clearOnyxOnStart} = Object.fromEntries( + const {email, authToken, encryptedAuthToken, accountID, autoGeneratedLogin, autoGeneratedPassword, clearOnyxOnStart} = Object.fromEntries( queryParams.split('&').map((param) => { const [key, value] = param.split('='); return [key, value]; @@ -491,7 +491,7 @@ function signInAfterTransitionFromOldDot(transitionURL: string) { const setSessionDataAndOpenApp = () => { Onyx.multiSet({ - [ONYXKEYS.SESSION]: {email, authToken, accountID: Number(accountID)}, + [ONYXKEYS.SESSION]: {email, authToken, encryptedAuthToken: decodeURIComponent(encryptedAuthToken), accountID: Number(accountID)}, [ONYXKEYS.CREDENTIALS]: {autoGeneratedLogin, autoGeneratedPassword}, }).then(App.openApp); }; diff --git a/src/libs/actions/Workflow.ts b/src/libs/actions/Workflow.ts index 4adb692919e..5e4b0408f15 100644 --- a/src/libs/actions/Workflow.ts +++ b/src/libs/actions/Workflow.ts @@ -37,11 +37,11 @@ Onyx.connect({ }, }); -let personalDetails: PersonalDetailsList | undefined; +let personalDetailsByEmail: PersonalDetailsList = {}; Onyx.connect({ key: ONYXKEYS.PERSONAL_DETAILS_LIST, - callback: (value) => { - personalDetails = value; + callback: (personalDetails) => { + personalDetailsByEmail = lodashMapKeys(personalDetails, (value, key) => value?.login ?? key); }, }); @@ -56,6 +56,11 @@ function createApprovalWorkflow(policyID: string, approvalWorkflow: ApprovalWork const previousApprovalMode = policy.approvalMode; const updatedEmployees = convertApprovalWorkflowToPolicyEmployees({previousEmployeeList, approvalWorkflow, type: CONST.APPROVAL_WORKFLOW.TYPE.CREATE}); + // If there are no changes to the employees list, we can exit early + if (isEmptyObject(updatedEmployees)) { + return; + } + const optimisticData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, @@ -127,6 +132,7 @@ function updateApprovalWorkflow(policyID: string, approvalWorkflow: ApprovalWork approversToRemove, }); + // If there are no changes to the employees list, we can exit early if (isEmptyObject(updatedEmployees) && !newDefaultApprover) { return; } @@ -258,12 +264,13 @@ function removeApprovalWorkflow(policyID: string, approvalWorkflow: ApprovalWork API.write(WRITE_COMMANDS.REMOVE_WORKSPACE_APPROVAL, parameters, {optimisticData, failureData, successData}); } +/** Set the members of the approval workflow that is currently edited */ function setApprovalWorkflowMembers(members: Member[]) { Onyx.merge(ONYXKEYS.APPROVAL_WORKFLOW, {members, errors: null}); } /** - * Set the approver at the specified index in the current approval workflow + * Set the approver at the specified index in the approval workflow that is currently edited * @param approver - The new approver to set * @param approverIndex - The index of the approver to set * @param policyID - The ID of the policy @@ -280,13 +287,14 @@ function setApprovalWorkflowApprover(approver: Approver, approverIndex: number, // Check if the approver forwards to other approvers and add them to the list if (policy.employeeList[approver.email]?.forwardsTo) { - const personalDetailsByEmail = lodashMapKeys(personalDetails, (value, key) => value?.login ?? key); const additionalApprovers = calculateApprovers({employees: policy.employeeList, firstEmail: approver.email, personalDetailsByEmail}); approvers.splice(approverIndex, approvers.length, ...additionalApprovers); } + // Always clear the additional approver error when an approver is added const errors: Record = {additionalApprover: null}; - // Check for circular references and reset errors + + // Check for circular references (approver forwards to themselves) and reset other errors const updatedApprovers = approvers.map((existingApprover, index) => { if (!existingApprover) { return; @@ -308,6 +316,7 @@ function setApprovalWorkflowApprover(approver: Approver, approverIndex: number, Onyx.merge(ONYXKEYS.APPROVAL_WORKFLOW, {approvers: updatedApprovers, errors}); } +/** Clear one approver at the specified index in the approval workflow that is currently edited */ function clearApprovalWorkflowApprover(approverIndex: number) { if (!currentApprovalWorkflow) { return; @@ -319,6 +328,7 @@ function clearApprovalWorkflowApprover(approverIndex: number) { Onyx.merge(ONYXKEYS.APPROVAL_WORKFLOW, {approvers: lodashDropRightWhile(approvers, (approver) => !approver), errors: null}); } +/** Clear all approvers of the approval workflow that is currently edited */ function clearApprovalWorkflowApprovers() { Onyx.merge(ONYXKEYS.APPROVAL_WORKFLOW, {approvers: []}); } @@ -333,6 +343,11 @@ function clearApprovalWorkflow() { type ApprovalWorkflowOnyxValidated = Omit & {approvers: Approver[]}; +/** + * Validates the approval workflow and sets the errors on the approval workflow + * @param approvalWorkflow the approval workflow to validate + * @returns true if the approval workflow is valid, false otherwise + */ function validateApprovalWorkflow(approvalWorkflow: ApprovalWorkflowOnyx): approvalWorkflow is ApprovalWorkflowOnyxValidated { const errors: Record = {}; @@ -355,8 +370,6 @@ function validateApprovalWorkflow(approvalWorkflow: ApprovalWorkflowOnyx): appro } Onyx.merge(ONYXKEYS.APPROVAL_WORKFLOW, {errors}); - - // Return false if there are errors return isEmptyObject(errors); } diff --git a/src/libs/fileDownload/index.android.ts b/src/libs/fileDownload/index.android.ts index 83255231d26..a1e81e47994 100644 --- a/src/libs/fileDownload/index.android.ts +++ b/src/libs/fileDownload/index.android.ts @@ -111,8 +111,7 @@ const postDownloadFile = (url: string, fileName?: string, formData?: FormData, o }) .then((fileData) => { const finalFileName = FileUtils.appendTimeToFileName(fileName ?? 'Expensify'); - const downloadPath = `${RNFS.DownloadDirectoryPath}/Expensify/${finalFileName}`; - + const downloadPath = `${RNFS.DownloadDirectoryPath}/${finalFileName}`; return RNFS.writeFile(downloadPath, fileData, 'utf8').then(() => downloadPath); }) .then((downloadPath) => diff --git a/src/pages/ErrorPage/NotFoundPage.tsx b/src/pages/ErrorPage/NotFoundPage.tsx index cfdeab9c51c..6a63a720421 100644 --- a/src/pages/ErrorPage/NotFoundPage.tsx +++ b/src/pages/ErrorPage/NotFoundPage.tsx @@ -2,18 +2,37 @@ import React from 'react'; import type {FullPageNotFoundViewProps} from '@components/BlockingViews/FullPageNotFoundView'; import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; import ScreenWrapper from '@components/ScreenWrapper'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import Navigation from '@libs/Navigation/Navigation'; +import * as ReportUtils from '@libs/ReportUtils'; type NotFoundPageProps = { onBackButtonPress?: () => void; + isReportRelatedPage?: boolean; } & FullPageNotFoundViewProps; // eslint-disable-next-line rulesdir/no-negated-variables -function NotFoundPage({onBackButtonPress, ...fullPageNotFoundViewProps}: NotFoundPageProps) { +function NotFoundPage({onBackButtonPress = () => Navigation.goBack(), isReportRelatedPage, ...fullPageNotFoundViewProps}: NotFoundPageProps) { + const {isSmallScreenWidth} = useResponsiveLayout(); + return ( { + if (!isReportRelatedPage || !isSmallScreenWidth) { + onBackButtonPress(); + return; + } + const topmostReportId = Navigation.getTopmostReportId(); + const report = ReportUtils.getReport(topmostReportId ?? ''); + // detect the report is invalid + if (topmostReportId && (!report || report.errorFields?.notFound)) { + Navigation.dismissModal(); + return; + } + onBackButtonPress(); + }} // eslint-disable-next-line react/jsx-props-no-spreading {...fullPageNotFoundViewProps} /> diff --git a/src/pages/InviteReportParticipantsPage.tsx b/src/pages/InviteReportParticipantsPage.tsx index 7f5ec7ec5fe..5a3b69aa628 100644 --- a/src/pages/InviteReportParticipantsPage.tsx +++ b/src/pages/InviteReportParticipantsPage.tsx @@ -28,6 +28,7 @@ import ROUTES from '@src/ROUTES'; import type {InvitedEmailsToAccountIDs, PersonalDetailsList} from '@src/types/onyx'; import type {WithReportOrNotFoundProps} from './home/report/withReportOrNotFound'; import withReportOrNotFound from './home/report/withReportOrNotFound'; +import SearchInputManager from './workspace/SearchInputManager'; type InviteReportParticipantsPageOnyxProps = { /** All of the personal details for everyone */ @@ -48,6 +49,11 @@ function InviteReportParticipantsPage({betas, personalDetails, report, didScreen const [searchTerm, debouncedSearchTerm, setSearchTerm] = useDebouncedState(''); const [selectedOptions, setSelectedOptions] = useState([]); + useEffect(() => { + setSearchTerm(SearchInputManager.searchInput); + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps + }, []); + // Any existing participants and Expensify emails should not be eligible for invitation const excludedUsers = useMemo( () => [...PersonalDetailsUtils.getLoginsByAccountIDs(ReportUtils.getParticipantsAccountIDsForDisplay(report, false, true)), ...CONST.EXPENSIFY_EMAILS], @@ -200,7 +206,10 @@ function InviteReportParticipantsPage({betas, personalDetails, report, didScreen { + SearchInputManager.searchInput = ''; + inviteUsers(); + }} containerStyles={[styles.flexReset, styles.flexGrow0, styles.flexShrink0, styles.flexBasisAuto]} enabledWhenOffline /> @@ -228,7 +237,10 @@ function InviteReportParticipantsPage({betas, personalDetails, report, didScreen ListItem={InviteMemberListItem} textInputLabel={translate('selectionList.nameEmailOrPhoneNumber')} textInputValue={searchTerm} - onChangeText={setSearchTerm} + onChangeText={(value) => { + SearchInputManager.searchInput = value; + setSearchTerm(value); + }} headerMessage={headerMessage} onSelectRow={toggleOption} onConfirm={inviteUsers} diff --git a/src/pages/ReimbursementAccount/BankAccountStep.tsx b/src/pages/ReimbursementAccount/BankAccountStep.tsx index 69e60587872..38ea48ba0ec 100644 --- a/src/pages/ReimbursementAccount/BankAccountStep.tsx +++ b/src/pages/ReimbursementAccount/BankAccountStep.tsx @@ -2,11 +2,10 @@ import React from 'react'; import {View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import {withOnyx} from 'react-native-onyx'; -import Button from '@components/Button'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import Icon from '@components/Icon'; import * as Expensicons from '@components/Icon/Expensicons'; -import * as Illustrations from '@components/Icon/Illustrations'; +import LottieAnimations from '@components/LottieAnimations'; import MenuItem from '@components/MenuItem'; import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback'; import ScreenWrapper from '@components/ScreenWrapper'; @@ -36,6 +35,9 @@ type BankAccountStepOnyxProps = { /** If the plaid button has been disabled */ isPlaidDisabled: OnyxEntry; + + /** List of bank accounts */ + bankAccountList: OnyxEntry; }; type BankAccountStepProps = BankAccountStepOnyxProps & { @@ -69,6 +71,7 @@ function BankAccountStep({ reimbursementAccount, onBackButtonPress, isPlaidDisabled = false, + bankAccountList = {}, }: BankAccountStepProps) { const theme = useTheme(); const styles = useThemeStyles(); @@ -80,6 +83,7 @@ function BankAccountStep({ } const plaidDesktopMessage = getPlaidDesktopMessage(); const bankAccountRoute = `${ROUTES.BANK_ACCOUNT_WITH_STEP_TO_OPEN.getRoute('new', policyID, ROUTES.WORKSPACE_INITIAL.getRoute(policyID))}`; + const personalBankAccounts = Object.keys(bankAccountList).filter((key) => bankAccountList[key].accountType === CONST.PAYMENT_METHODS.PERSONAL_BANK_ACCOUNT); const removeExistingBankAccountDetails = () => { const bankAccountData: Partial = { @@ -119,34 +123,50 @@ function BankAccountStep({ />
- - {translate('bankAccount.toGetStarted')} - {!!plaidDesktopMessage && ( - + Link.openExternalLinkWithToken(bankAccountRoute)}>{translate(plaidDesktopMessage)} )} -