diff --git a/.github/scripts/verifyDeploy.sh b/.github/scripts/verifyDeploy.sh new file mode 100755 index 000000000000..0a8fd3c97bcf --- /dev/null +++ b/.github/scripts/verifyDeploy.sh @@ -0,0 +1,34 @@ +#!/bin/bash + +ENV="$1" +EXPECTED_VERSION="$2" + +BASE_URL="" +if [[ "$ENV" == 'staging' ]]; then + BASE_URL='https://staging.new.expensify.com' +else + BASE_URL='https://new.expensify.com' +fi + +sleep 5 +ATTEMPT=0 +MAX_ATTEMPTS=10 +while [[ $ATTEMPT -lt $MAX_ATTEMPTS ]]; do + ((ATTEMPT++)) + + echo "Attempt $ATTEMPT: Checking deployed version..." + DOWNLOADED_VERSION="$(wget -q -O /dev/stdout "$BASE_URL"/version.json | jq -r '.version')" + + if [[ "$EXPECTED_VERSION" == "$DOWNLOADED_VERSION" ]]; then + echo "Success: Deployed version matches local version: $DOWNLOADED_VERSION" + exit 0 + fi + + if [[ $ATTEMPT -lt $MAX_ATTEMPTS ]]; then + echo "Version mismatch, found $DOWNLOADED_VERSION. Retrying in 5 seconds..." + sleep 5 + fi +done + +echo "Error: Deployed version did not match local version after $MAX_ATTEMPTS attempts. Something went wrong..." +exit 1 diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 53afe03720f7..99cd0c1dabc5 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -386,23 +386,11 @@ jobs: - name: Verify staging deploy if: ${{ !fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} - run: | - sleep 5 - DOWNLOADED_VERSION="$(wget -q -O /dev/stdout https://staging.new.expensify.com/version.json | jq -r '.version')" - 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 + run: ./.github/scripts/verifyDeploy.sh staging ${{ needs.prep.outputs.APP_VERSION }} - name: Verify production deploy if: ${{ fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} - run: | - sleep 5 - DOWNLOADED_VERSION="$(wget -q -O /dev/stdout https://new.expensify.com/version.json | jq -r '.version')" - 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 + run: ./.github/scripts/verifyDeploy.sh production ${{ needs.prep.outputs.APP_VERSION }} - name: Upload web sourcemaps artifact uses: actions/upload-artifact@v4 @@ -507,11 +495,13 @@ jobs: GITHUB_TOKEN: ${{ github.token }} - name: Rename web and desktop sourcemaps artifacts before assets upload in order to have unique ReleaseAsset.name + continue-on-error: true run: | mv ./desktop-staging-sourcemaps-artifact/merged-source-map.js.map ./desktop-staging-sourcemaps-artifact/desktop-staging-merged-source-map.js.map mv ./web-staging-sourcemaps-artifact/merged-source-map.js.map ./web-staging-sourcemaps-artifact/web-staging-merged-source-map.js.map - name: Upload artifacts to GitHub Release + continue-on-error: true run: | gh release upload ${{ needs.prep.outputs.APP_VERSION }} --repo ${{ github.repository }} --clobber \ ./android-sourcemaps-artifact/index.android.bundle.map#android-sourcemap-${{ needs.prep.outputs.APP_VERSION }} \ @@ -552,11 +542,6 @@ jobs: - name: Download all workflow run artifacts uses: actions/download-artifact@v4 - - name: Rename web and desktop sourcemaps artifacts before assets upload in order to have unique ReleaseAsset.name - run: | - mv ./desktop-sourcemaps-artifact/merged-source-map.js.map ./desktop-sourcemaps-artifact/desktop-merged-source-map.js.map - mv ./web-sourcemaps-artifact/merged-source-map.js.map ./web-sourcemaps-artifact/web-merged-source-map.js.map - - name: 🚀 Edit the release to be no longer a prerelease 🚀 run: | LATEST_RELEASE="$(gh release list --repo ${{ github.repository }} --exclude-pre-releases --json tagName,isLatest --jq '.[] | select(.isLatest) | .tagName')" @@ -565,7 +550,14 @@ jobs: env: GITHUB_TOKEN: ${{ github.token }} + - name: Rename web and desktop sourcemaps artifacts before assets upload in order to have unique ReleaseAsset.name + continue-on-error: true + run: | + mv ./desktop-sourcemaps-artifact/merged-source-map.js.map ./desktop-sourcemaps-artifact/desktop-merged-source-map.js.map + mv ./web-sourcemaps-artifact/merged-source-map.js.map ./web-sourcemaps-artifact/web-merged-source-map.js.map + - name: Upload artifacts to GitHub Release + continue-on-error: true run: | gh release upload ${{ needs.prep.outputs.APP_VERSION }} --repo ${{ github.repository }} --clobber \ ./desktop-sourcemaps-artifact/desktop-merged-source-map.js.map#desktop-sourcemap-${{ needs.prep.outputs.APP_VERSION }} \ diff --git a/.github/workflows/deployNewHelp.yml b/.github/workflows/deployNewHelp.yml new file mode 100644 index 000000000000..8c4d0fb0ae3b --- /dev/null +++ b/.github/workflows/deployNewHelp.yml @@ -0,0 +1,74 @@ +name: Deploy New Help Site + +on: + # Run on any push to main that has changes to the help directory +# TEST: Verify Cloudflare picks this up even if not run when merged to main +# push: +# branches: +# - main +# paths: +# - 'help/**' + + # Run on any pull request (except PRs against staging or production) that has changes to the help directory + pull_request: + types: [opened, synchronize] + branches-ignore: [staging, production] + paths: + - 'help/**' + + # Run on any manual trigger + workflow_dispatch: + +# Allow only one concurrent deployment +concurrency: + group: "newhelp" + cancel-in-progress: false + +jobs: + build: + env: + IS_PR_FROM_FORK: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork }} + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + # Set up Ruby and run bundle install inside the /help directory + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + bundler-cache: true + working-directory: ./help + + - name: Build Jekyll site + run: bundle exec jekyll build --source ./ --destination ./_site + working-directory: ./help # Ensure Jekyll is building the site in /help + + - name: Deploy to Cloudflare Pages + uses: cloudflare/pages-action@v1 + id: deploy + if: env.IS_PR_FROM_FORK != 'true' + with: + apiToken: ${{ secrets.CLOUDFLARE_PAGES_TOKEN }} + accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + projectName: newhelp + directory: ./help/_site # Deploy the built site + + - name: Setup Cloudflare CLI + if: env.IS_PR_FROM_FORK != 'true' + run: pip3 install cloudflare==2.19.0 + + - name: Purge Cloudflare cache + if: env.IS_PR_FROM_FORK != 'true' + run: /home/runner/.local/bin/cli4 --verbose --delete hosts=["newhelp.expensify.com"] /zones/:9ee042e6cfc7fd45e74aa7d2f78d617b/purge_cache + env: + CF_API_KEY: ${{ secrets.CLOUDFLARE_TOKEN }} + + - name: Leave a comment on the PR + uses: actions-cool/maintain-one-comment@v3.2.0 + if: ${{ github.event_name == 'pull_request' && env.IS_PR_FROM_FORK != 'true' }} + with: + token: ${{ github.token }} + body: ${{ format('A preview of your New Help changes have been deployed to {0} :zap:️', steps.deploy.outputs.alias) }} + diff --git a/.github/workflows/preDeploy.yml b/.github/workflows/preDeploy.yml index 796468170275..bfe860e60224 100644 --- a/.github/workflows/preDeploy.yml +++ b/.github/workflows/preDeploy.yml @@ -4,7 +4,7 @@ name: Process new code merged to main on: push: branches: [main] - paths-ignore: [docs/**, contributingGuides/**, jest/**, tests/**] + paths-ignore: [docs/**, help/**, contributingGuides/**, jest/**, tests/**] jobs: typecheck: diff --git a/.github/workflows/reassurePerformanceTests.yml b/.github/workflows/reassurePerformanceTests.yml index d4a25a63952b..fb7a34d6fa01 100644 --- a/.github/workflows/reassurePerformanceTests.yml +++ b/.github/workflows/reassurePerformanceTests.yml @@ -4,7 +4,7 @@ on: pull_request: types: [opened, synchronize] branches-ignore: [staging, production] - paths-ignore: [docs/**, .github/**, contributingGuides/**, tests/**, '**.md', '**.sh'] + paths-ignore: [docs/**, help/**, .github/**, contributingGuides/**, tests/**, '**.md', '**.sh'] jobs: perf-tests: diff --git a/.github/workflows/sendReassurePerfData.yml b/.github/workflows/sendReassurePerfData.yml index 42d946cece95..884182bfc896 100644 --- a/.github/workflows/sendReassurePerfData.yml +++ b/.github/workflows/sendReassurePerfData.yml @@ -3,7 +3,7 @@ name: Send Reassure Performance Tests to Graphite on: push: branches: [main] - paths-ignore: [docs/**, contributingGuides/**, jest/**] + paths-ignore: [docs/**, help/**, contributingGuides/**, jest/**] jobs: perf-tests: diff --git a/.prettierignore b/.prettierignore index a9f7e1464529..98d06e8c5f71 100644 --- a/.prettierignore +++ b/.prettierignore @@ -15,6 +15,7 @@ package-lock.json *.css *.scss *.md +*.markdown # We need to modify the import here specifically, hence we disable prettier to get rid of the sorted imports src/libs/E2E/reactNativeLaunchingTest.ts # Temporary while we keep react-compiler in our repo diff --git a/android/app/build.gradle b/android/app/build.gradle index 9153966e1d5d..2491cc21a400 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 1009004004 - versionName "9.0.40-4" + versionCode 1009004102 + versionName "9.0.41-2" // 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/new-expensify/connections/quickbooks-online/Configure-Quickbooks-Online.md b/docs/articles/new-expensify/connections/quickbooks-online/Configure-Quickbooks-Online.md index 787602337bd2..73e3340d41a2 100644 --- a/docs/articles/new-expensify/connections/quickbooks-online/Configure-Quickbooks-Online.md +++ b/docs/articles/new-expensify/connections/quickbooks-online/Configure-Quickbooks-Online.md @@ -1,9 +1,76 @@ --- title: Configure Quickbooks Online -description: Coming Soon +description: Configure your QuickBooks Online connection with Expensify --- -# FAQ +Once you've set up your QuickBooks Online connection, you'll be able to configure your import and export settings. + +# Step 1: Configure import settings + +The following steps help you determine how data will be imported from QuickBooks Online to Expensify. + +
    +
  1. Under the Accounting settings for your workspace, click Import under the QuickBooks Online connection.
  2. +
  3. Review each of the following import settings:
  4. + +
+ +# Step 2: Configure export settings + +The following steps help you determine how data will be exported from Expensify to QuickBooks Online. + +
    +
  1. Under the Accounting settings for your workspace, click Export under the QuickBooks Online connection.
  2. +
  3. Review each of the following export settings:
  4. + +
+ +# Step 3: Configure advanced settings + +The following steps help you determine the advanced settings for your connection, like auto-sync and employee invitation settings. + +
    +
  1. Under the Accounting settings for your workspace, click Advanced under the QuickBooks Online connection.
  2. +
  3. Select an option for each of the following settings:
  4. + +
+ +{% include faq-begin.md %} ## How do I know if a report is successfully exported to QuickBooks Online? @@ -22,3 +89,5 @@ When an admin manually exports a report, Expensify will notify them if the repor - If a report has been exported and marked as paid in QuickBooks Online, it will be automatically marked as reimbursed in Expensify during the next sync. Reports that have yet to be exported to QuickBooks Online won’t be automatically exported. + +{% include faq-end.md %} diff --git a/docs/articles/new-expensify/expenses-&-payments/Pay-an-invoice.md b/docs/articles/new-expensify/expenses-&-payments/Pay-an-invoice.md index 727c6b86b7a6..615fac731c41 100644 --- a/docs/articles/new-expensify/expenses-&-payments/Pay-an-invoice.md +++ b/docs/articles/new-expensify/expenses-&-payments/Pay-an-invoice.md @@ -32,6 +32,8 @@ To pay an invoice, You can also view all unpaid invoices by searching for the sender’s email or phone number on the left-hand side of the app. The invoices waiting for your payment will have a green dot. +![Click Pay Button on the Invoice]({{site.url}}/assets/images/ExpensifyHelp-Invoice-1.png){:width="100%"} + {% include faq-begin.md %} **Can someone else pay an invoice besides the person who received it?** diff --git a/docs/articles/new-expensify/expenses-&-payments/Send-an-invoice.md b/docs/articles/new-expensify/expenses-&-payments/Send-an-invoice.md index 85bd6b655186..57b81a031a01 100644 --- a/docs/articles/new-expensify/expenses-&-payments/Send-an-invoice.md +++ b/docs/articles/new-expensify/expenses-&-payments/Send-an-invoice.md @@ -57,6 +57,18 @@ Only workspace admins can send invoices. Invoices can be sent directly from Expe {% include end-selector.html %} +![Go to Account Settings click Workspace]({{site.url}}/assets/images/invoices_01.png){:width="100%"} + +![Click More Features for the workspace and enable Invoices]({{site.url}}/assets/images/invoices_02.png){:width="100%"} + +![Click the green button Send Invoice]({{site.url}}/assets/images/invoices_03.png){:width="100%"} + +![Enter Invoice amount]({{site.url}}/assets/images/invoices_04.png){:width="100%"} + +![Choose a recipient]({{site.url}}/assets/images/invoices_05.png){:width="100%"} + +![Add Invoice details and Send Invoice]({{site.url}}/assets/images/invoices_06.png){:width="100%"} + # Receive invoice payment If you have not [connected a business bank account](https://help.expensify.com/articles/new-expensify/expenses-&-payments/Connect-a-Business-Bank-Account) to receive invoice payments, you will see an **Invoice balance** in your [Wallet](https://help.expensify.com/articles/new-expensify/expenses-&-payments/Set-up-your-wallet). Expensify will automatically transfer these invoice payments once a business bank account is connected. diff --git a/help/.gitignore b/help/.gitignore new file mode 100644 index 000000000000..f40fbd8ba564 --- /dev/null +++ b/help/.gitignore @@ -0,0 +1,5 @@ +_site +.sass-cache +.jekyll-cache +.jekyll-metadata +vendor diff --git a/help/.ruby-version b/help/.ruby-version new file mode 100644 index 000000000000..a0891f563f38 --- /dev/null +++ b/help/.ruby-version @@ -0,0 +1 @@ +3.3.4 diff --git a/help/404.html b/help/404.html new file mode 100644 index 000000000000..086a5c9ea988 --- /dev/null +++ b/help/404.html @@ -0,0 +1,25 @@ +--- +permalink: /404.html +layout: default +--- + + + +
+

404

+ +

Page not found :(

+

The requested page could not be found.

+
diff --git a/help/Gemfile b/help/Gemfile new file mode 100644 index 000000000000..4f2e425b8aba --- /dev/null +++ b/help/Gemfile @@ -0,0 +1,19 @@ +source "https://rubygems.org" + +gem "jekyll", "~> 4.3.4" +gem "minima", "~> 2.5" +gem "nokogiri" + +group :jekyll_plugins do + gem "jekyll-feed", "~> 0.12" +end + +# If using tzinfo-data for timezone support, ensure it's bundled for relevant platforms +platforms :mingw, :x64_mingw, :mswin, :jruby do + gem "tzinfo", ">= 1", "< 3" + gem "tzinfo-data" +end + +gem "wdm", "~> 0.1", platforms: [:mingw, :x64_mingw, :mswin] +gem "http_parser.rb", "~> 0.6.0", platforms: [:jruby] + diff --git a/help/Gemfile.lock b/help/Gemfile.lock new file mode 100644 index 000000000000..7434e1c4e935 --- /dev/null +++ b/help/Gemfile.lock @@ -0,0 +1,200 @@ +GEM + remote: https://rubygems.org/ + specs: + addressable (2.8.7) + public_suffix (>= 2.0.2, < 7.0) + bigdecimal (3.1.8) + colorator (1.1.0) + concurrent-ruby (1.3.4) + em-websocket (0.5.3) + eventmachine (>= 0.12.9) + http_parser.rb (~> 0) + eventmachine (1.2.7) + ffi (1.17.0) + ffi (1.17.0-aarch64-linux-gnu) + ffi (1.17.0-aarch64-linux-musl) + ffi (1.17.0-arm-linux-gnu) + ffi (1.17.0-arm-linux-musl) + ffi (1.17.0-arm64-darwin) + ffi (1.17.0-x86-linux-gnu) + ffi (1.17.0-x86-linux-musl) + ffi (1.17.0-x86_64-darwin) + ffi (1.17.0-x86_64-linux-gnu) + ffi (1.17.0-x86_64-linux-musl) + forwardable-extended (2.6.0) + google-protobuf (4.28.2) + bigdecimal + rake (>= 13) + google-protobuf (4.28.2-aarch64-linux) + bigdecimal + rake (>= 13) + google-protobuf (4.28.2-arm64-darwin) + bigdecimal + rake (>= 13) + google-protobuf (4.28.2-x86-linux) + bigdecimal + rake (>= 13) + google-protobuf (4.28.2-x86_64-darwin) + bigdecimal + rake (>= 13) + google-protobuf (4.28.2-x86_64-linux) + bigdecimal + rake (>= 13) + http_parser.rb (0.8.0) + i18n (1.14.6) + concurrent-ruby (~> 1.0) + jekyll (4.3.4) + addressable (~> 2.4) + colorator (~> 1.0) + em-websocket (~> 0.5) + i18n (~> 1.0) + jekyll-sass-converter (>= 2.0, < 4.0) + jekyll-watch (~> 2.0) + kramdown (~> 2.3, >= 2.3.1) + kramdown-parser-gfm (~> 1.0) + liquid (~> 4.0) + mercenary (>= 0.3.6, < 0.5) + pathutil (~> 0.9) + rouge (>= 3.0, < 5.0) + safe_yaml (~> 1.0) + terminal-table (>= 1.8, < 4.0) + webrick (~> 1.7) + jekyll-feed (0.17.0) + jekyll (>= 3.7, < 5.0) + jekyll-sass-converter (3.0.0) + sass-embedded (~> 1.54) + jekyll-seo-tag (2.8.0) + jekyll (>= 3.8, < 5.0) + jekyll-watch (2.2.1) + listen (~> 3.0) + kramdown (2.4.0) + rexml + kramdown-parser-gfm (1.1.0) + kramdown (~> 2.0) + liquid (4.0.4) + listen (3.9.0) + rb-fsevent (~> 0.10, >= 0.10.3) + rb-inotify (~> 0.9, >= 0.9.10) + mercenary (0.4.0) + mini_portile2 (2.8.7) + minima (2.5.2) + jekyll (>= 3.5, < 5.0) + jekyll-feed (~> 0.9) + jekyll-seo-tag (~> 2.1) + nokogiri (1.16.7) + mini_portile2 (~> 2.8.2) + racc (~> 1.4) + nokogiri (1.16.7-aarch64-linux) + racc (~> 1.4) + nokogiri (1.16.7-arm-linux) + racc (~> 1.4) + nokogiri (1.16.7-arm64-darwin) + racc (~> 1.4) + nokogiri (1.16.7-x86-linux) + racc (~> 1.4) + nokogiri (1.16.7-x86_64-darwin) + racc (~> 1.4) + nokogiri (1.16.7-x86_64-linux) + racc (~> 1.4) + pathutil (0.16.2) + forwardable-extended (~> 2.6) + public_suffix (6.0.1) + racc (1.8.1) + rake (13.2.1) + rb-fsevent (0.11.2) + rb-inotify (0.11.1) + ffi (~> 1.0) + rexml (3.3.7) + rouge (4.4.0) + safe_yaml (1.0.5) + sass-embedded (1.79.3) + google-protobuf (~> 4.27) + rake (>= 13) + sass-embedded (1.79.3-aarch64-linux-android) + google-protobuf (~> 4.27) + sass-embedded (1.79.3-aarch64-linux-gnu) + google-protobuf (~> 4.27) + sass-embedded (1.79.3-aarch64-linux-musl) + google-protobuf (~> 4.27) + sass-embedded (1.79.3-aarch64-mingw-ucrt) + google-protobuf (~> 4.27) + sass-embedded (1.79.3-arm-linux-androideabi) + google-protobuf (~> 4.27) + sass-embedded (1.79.3-arm-linux-gnueabihf) + google-protobuf (~> 4.27) + sass-embedded (1.79.3-arm-linux-musleabihf) + google-protobuf (~> 4.27) + sass-embedded (1.79.3-arm64-darwin) + google-protobuf (~> 4.27) + sass-embedded (1.79.3-riscv64-linux-android) + google-protobuf (~> 4.27) + sass-embedded (1.79.3-riscv64-linux-gnu) + google-protobuf (~> 4.27) + sass-embedded (1.79.3-riscv64-linux-musl) + google-protobuf (~> 4.27) + sass-embedded (1.79.3-x86-cygwin) + google-protobuf (~> 4.27) + sass-embedded (1.79.3-x86-linux-android) + google-protobuf (~> 4.27) + sass-embedded (1.79.3-x86-linux-gnu) + google-protobuf (~> 4.27) + sass-embedded (1.79.3-x86-linux-musl) + google-protobuf (~> 4.27) + sass-embedded (1.79.3-x86-mingw-ucrt) + google-protobuf (~> 4.27) + sass-embedded (1.79.3-x86_64-cygwin) + google-protobuf (~> 4.27) + sass-embedded (1.79.3-x86_64-darwin) + google-protobuf (~> 4.27) + sass-embedded (1.79.3-x86_64-linux-android) + google-protobuf (~> 4.27) + sass-embedded (1.79.3-x86_64-linux-gnu) + google-protobuf (~> 4.27) + sass-embedded (1.79.3-x86_64-linux-musl) + google-protobuf (~> 4.27) + terminal-table (3.0.2) + unicode-display_width (>= 1.1.1, < 3) + unicode-display_width (2.6.0) + webrick (1.8.2) + +PLATFORMS + aarch64-linux + aarch64-linux-android + aarch64-linux-gnu + aarch64-linux-musl + aarch64-mingw-ucrt + arm-linux-androideabi + arm-linux-gnu + arm-linux-gnueabihf + arm-linux-musl + arm-linux-musleabihf + arm64-darwin + riscv64-linux-android + riscv64-linux-gnu + riscv64-linux-musl + ruby + x86-cygwin + x86-linux + x86-linux-android + x86-linux-gnu + x86-linux-musl + x86-mingw-ucrt + x86_64-cygwin + x86_64-darwin + x86_64-linux + x86_64-linux-android + x86_64-linux-gnu + x86_64-linux-musl + +DEPENDENCIES + http_parser.rb (~> 0.6.0) + jekyll (~> 4.3.4) + jekyll-feed (~> 0.12) + minima (~> 2.5) + nokogiri + tzinfo (>= 1, < 3) + tzinfo-data + wdm (~> 0.1) + +BUNDLED WITH + 2.5.19 diff --git a/help/_config.yml b/help/_config.yml new file mode 100644 index 000000000000..9135a372964e --- /dev/null +++ b/help/_config.yml @@ -0,0 +1,7 @@ +title: New Expensify Help +email: concierge@expensify.com +description: Comprehensive help documentation for New Expensify. +url: https://newhelp.expensify.com +twitter_username: expensify +github_username: expensify + diff --git a/help/_layouts/default.html b/help/_layouts/default.html new file mode 100644 index 000000000000..cf95e1f54b06 --- /dev/null +++ b/help/_layouts/default.html @@ -0,0 +1,25 @@ + + + + + + {{ page.title }} + + +
+ +
+ + +
+ {{ content }} +
+ + + + + diff --git a/help/_layouts/product.html b/help/_layouts/product.html new file mode 100644 index 000000000000..cb8b5e882f24 --- /dev/null +++ b/help/_layouts/product.html @@ -0,0 +1,11 @@ +--- +layout: default +--- + +

{{ page.title }}

+ + +
+ {{ content }} +
+ diff --git a/help/_plugins/51_HeaderIDPostRender.rb b/help/_plugins/51_HeaderIDPostRender.rb new file mode 100644 index 000000000000..4af97cc788f6 --- /dev/null +++ b/help/_plugins/51_HeaderIDPostRender.rb @@ -0,0 +1,59 @@ +require 'nokogiri' +require 'cgi' # Use CGI for URL encoding + +module Jekyll + class HeaderIDPostRender + # Hook into Jekyll's post_render stage to ensure we work with the final HTML + Jekyll::Hooks.register :pages, :post_render, priority: 51 do |page| + process_page(page) + end + + Jekyll::Hooks.register :documents, :post_render, priority: 51 do |post| + process_page(post) + end + + def self.process_page(page) + return unless page.output_ext == ".html" # Only apply to HTML pages + return if page.output.nil? # Skip if no output has been generated + + puts " Processing page: #{page.path}" + + # Parse the page's content for header elements + doc = Nokogiri::HTML(page.output) + h1_id = "" + h2_id = "" + h3_id = "" + + # Process all

,

, and

elements + (2..4).each do |level| + doc.css("h#{level}").each do |header| + header_text = header.text.strip.downcase + header_id = CGI.escape(header_text.gsub(/\s+/, '-').gsub(/[^\w\-]/, '')) + + puts " Found h#{level}: '#{header_text}' -> ID: '#{header_id}'" + + # Create hierarchical IDs by appending to the parent header IDs + if level == 2 + h2_id = header_id + header['id'] = h2_id + elsif level == 3 + h3_id = "#{h2_id}:#{header_id}" + header['id'] = h3_id + elsif level == 4 + h4_id = "#{h3_id}:#{header_id}" + header['id'] = h4_id + end + + puts " Assigned ID: #{header['id']}" + end + end + + # Log the final output being written + puts " Writing updated HTML for page: #{page.path}" + + # Write the updated HTML back to the page + page.output = doc.to_html + end + end +end + diff --git a/help/index.md b/help/index.md new file mode 100644 index 000000000000..e5d075402ecb --- /dev/null +++ b/help/index.md @@ -0,0 +1,5 @@ +--- +title: New Expensify Help +--- +Pages: +* [Expensify Superapp](/superapp.html) diff --git a/help/robots.txt b/help/robots.txt new file mode 100644 index 000000000000..6ffbc308f73e --- /dev/null +++ b/help/robots.txt @@ -0,0 +1,3 @@ +User-agent: * +Disallow: / + diff --git a/help/superapp.md b/help/superapp.md new file mode 100644 index 000000000000..d09860a1ce7e --- /dev/null +++ b/help/superapp.md @@ -0,0 +1,115 @@ +--- +layout: product +title: Expensify Superapp +--- + +## Introduction +The Expensify Superapp packs the full power of 6 world class business, finance, and collaboration products, into a single app that works identically on desktop and mobile, efficiently with your colleagues, and seamlessly with your customers, vendors, family, and friends. + +### When should I use Expensify? +Expensify can do a lot. You should check us out whenever you need to: + +Track and manage expenses +: Whether you are reimbursing employee receipts, deducting personal expenses, or just splitting the bill, Expensify Expense is for you. + +Issue corporate cards +: Skip the reimbursement and capture receipts electronically in realtime by issuing the Expensify Card to yourself and your employees. + +Book and manage travel +: If you are booking your own business trip, arranging a trip for a colleague, or managing the travel of your whole company, Expensify Travel has got you covered. + +Chat with friends and coworkers +: Whether it's collaborating with your team, supporting you client, negotiating with your vendor, or just saying Hi to a friend, Expensify Chat connects you with anyone with an email address or SMS number + +Collect invoice payments online +: Expensify Invoice allows you to collect online payments from consumers and businesses alike – anyone with an email address or SMS number. + +Approve and pay bills online +: Scan, process, and approve bills online using Expensify Billpay, then we'll pay them electronically or via check, whatever they prefer. + +If you send, receive, or spend money – or even just talk to literally anyone, about literally anything – Expensify is the tool for you. + +### Who uses Expensify? +Expensify offers something for everyone. Some people who commonly use us include: + +Individuals +: Millions of individuals use Expensify to track personal expenses to maximize their tax deductions, stay within personal budgets, or just see where their money is going. + +Friends +: Expensify is a great way to split bills with friends, whether it's monthly rent and household expenses, a big ticket bachelorette party, or just grabbing drinks with friends. + +Employees +: Road warriors and desk jockeys alike count on Expensify to reimburse expense reports they create in international airports, swanky hotels, imposing conference centers, quaint coffeeshops, and boring office supply stores around the world. + +Managers +: Bosses manage corporate spend with Expensify to empower their best (and keep tabs on their… not so best), staying ahead of schedule and under budget. + +Accountants +: Internal accountants, fractional CFOs, CAS practices – you name it, they use Expensify to Invoice customers, process vendor bills, capture eReceipts, manage corporate spend: the whole shebang. If you're an accountant, we're already best friends. + +Travel managers +: Anyone looking to manage employee travel has come to the right place. + +If you are a person online who does basically anything, you can probably do it with Expensify. + +### Why should I use Expensify? +Though we do a lot, you've got a lot of options for everything we do. But you should use us because we are: +Simple enough for individuals - We've worked extremely hard to make a product that strips out all the complex jargon and enterprise baggage, and gives you a simple tool that doesn't overwhelm you with functionality and language you don't understand. + +Powerful enough for enterprises +: We've worked extremely hard to make a product that "scales up" to reveal increasingly sophisticated features, but only to those who need it, and only when they need it. Expensify is used by public companies, multinational companies, companies with tens of thousands of employees, non-profits, investment firms, accounting firms, manufacturers, and basically every industry in every currency and in every country around the world. If you are a company, we can support your needs, no matter how big or small. + +6 products for the price of 1 +: Do you pay for an expense management system? A corporate card? A travel management platform? An enterprise chat tool? An invoicing tool? A billpay tool? Now you don't need to. Expensify's superapp design allows us to offer ALL these features on a single platform, at probably less than what you pay for any of them individually. + +Supports everyone everywhere +: Expensify works on iPhones and Androids, desktops and browsers. We support every currency, and can reimburse to almost any country. You don't need to be an IT wizard – if you can type in their email address or SMS number, you can do basically everything with them. + +You get paid to use it +: Do you spend money? Spend it on the Expensify Card and we pay you up to 2% cashback. It's your money after all. + +Revenue share for accountants +: Do you manage the books for a bunch of clients? Become an Expensify Approved Accountant and take home 0.5% revenue share. Or share it with your clients as a discount, up to you! + +You are in the driver's seat; we're here to earn your business. But we're going to work harder for you than the other guys, and you won't be disappointed. + +## Concepts +The Expensify Superapp has a lot of moving pieces, so let's break them down one by one. + +### What makes Expensify a superapp? +A "superapp" is a single app that combines multiple products into one seamlessly interconnected experience. Expensify isn't a "suite" of separate products linked through a single account – Expensify is a single app with a single core design that can perform multiple product functions. The secret to making such a seamless experience is that we build all product functions atop the same common core: + +App +: The basis of the superapp experience is the actual app itself, which runs on your mobile phone or desktop computer. (What is the Expensify app?) + +Chats +: Even if you don't plan on using Expensify Chat for enterprise-grade workspace collaboration, chat is infused through the entire product. (What is a chat?) + +Expense +: Even if you aren't actively managing your expenses, you've still got them. Every product that deals with money is ultimately dealing with expenses of some kind. (What is an expense?) + +Workspace +: Though Expensify works great for our millions of individual members, every product really shines when used between groups of members sharing a "workspace". (What is a workspace?) + +Domain +: To support more advanced security features, many products provide extra functionality to members who are on the same email "domain". (What is a domain?) + +These are the foundational concepts you'll see again and again that underpin the superapp as a whole. + +### What is the Expensify app? +Just like your eyes are a window to your soul, the Expensify App is the doorway through which you experience the entire global world of interconnected chat-centric collaborative data that comprises the Expensify network. The main tools of this app consist of: + +Inbox +: The main screen of the app is the Inbox, which highlights exactly what you should do next, consolidated across all products. (What does the Inbox do?) + +Search +: The next major screen is Search, which as you'd expect, let's you search everything across all products, from one convenient and powerful place. (What does Search do?) + +Settings +: Settings wraps up all your personal, workspace, and domain configuration options, all in one helpful space. (What are Expensify's settings?) + +Create +: Finally, the big green plus button is the Create button, which lets you create pretty much anything, across all the products. (What does the Create button do?) + +It's a deceptively simple app, with a few very familiar looking screens and buttons that unlock an incredible range of sophisticated multi-product power. + diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index af696a13c998..2de5297dd7fb 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -19,7 +19,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 9.0.40 + 9.0.41 CFBundleSignature ???? CFBundleURLTypes @@ -40,7 +40,7 @@ CFBundleVersion - 9.0.40.4 + 9.0.41.2 FullStory OrgId diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index 0795209286ed..31fc4454214c 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -15,10 +15,10 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 9.0.40 + 9.0.41 CFBundleSignature ???? CFBundleVersion - 9.0.40.4 + 9.0.41.2 diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index a545bd82c164..0abd6fae99d5 100644 --- a/ios/NotificationServiceExtension/Info.plist +++ b/ios/NotificationServiceExtension/Info.plist @@ -11,9 +11,9 @@ CFBundleName $(PRODUCT_NAME) CFBundleShortVersionString - 9.0.40 + 9.0.41 CFBundleVersion - 9.0.40.4 + 9.0.41.2 NSExtension NSExtensionPointIdentifier diff --git a/package-lock.json b/package-lock.json index d43c8fee25c5..0ef9b9b19012 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "9.0.40-4", + "version": "9.0.41-2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "9.0.40-4", + "version": "9.0.41-2", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -112,7 +112,7 @@ "react-native-svg": "15.6.0", "react-native-tab-view": "^3.5.2", "react-native-url-polyfill": "^2.0.0", - "react-native-view-shot": "3.8.0", + "react-native-view-shot": "4.0.0-alpha.3", "react-native-vision-camera": "4.0.0-beta.13", "react-native-web": "^0.19.12", "react-native-web-sound": "^0.1.3", @@ -35667,8 +35667,9 @@ } }, "node_modules/react-native-view-shot": { - "version": "3.8.0", - "license": "MIT", + "version": "4.0.0-alpha.3", + "resolved": "https://registry.npmjs.org/react-native-view-shot/-/react-native-view-shot-4.0.0-alpha.3.tgz", + "integrity": "sha512-o0KVgC6XZqWmLUKVc4q6Ev1QW1kA4g/TF45wj8CgYS13wJuWYJ+nPGCHT9C2jvX/L65mtTollKXp2L8hbDnelg==", "dependencies": { "html2canvas": "^1.4.1" }, diff --git a/package.json b/package.json index e8322960c61c..aed1cf9a2c3f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "9.0.40-4", + "version": "9.0.41-2", "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.", @@ -169,7 +169,7 @@ "react-native-svg": "15.6.0", "react-native-tab-view": "^3.5.2", "react-native-url-polyfill": "^2.0.0", - "react-native-view-shot": "3.8.0", + "react-native-view-shot": "4.0.0-alpha.3", "react-native-vision-camera": "4.0.0-beta.13", "react-native-web": "^0.19.12", "react-native-web-sound": "^0.1.3", diff --git a/patches/react-native-draggable-flatlist+4.0.1.patch b/patches/react-native-draggable-flatlist+4.0.1.patch new file mode 100644 index 000000000000..348f1aa5de8a --- /dev/null +++ b/patches/react-native-draggable-flatlist+4.0.1.patch @@ -0,0 +1,94 @@ +diff --git a/node_modules/react-native-draggable-flatlist/src/components/DraggableFlatList.tsx b/node_modules/react-native-draggable-flatlist/src/components/DraggableFlatList.tsx +index d7d98c2..2f59c7a 100644 +--- a/node_modules/react-native-draggable-flatlist/src/components/DraggableFlatList.tsx ++++ b/node_modules/react-native-draggable-flatlist/src/components/DraggableFlatList.tsx +@@ -295,7 +295,7 @@ function DraggableFlatListInner(props: DraggableFlatListProps) { + const springTo = placeholderOffset.value - activeCellOffset.value; + touchTranslate.value = withSpring( + springTo, +- animationConfigRef.current, ++ animationConfigRef.value, + () => { + runOnJS(onDragEnd)({ + from: activeIndexAnim.value, +diff --git a/node_modules/react-native-draggable-flatlist/src/context/refContext.tsx b/node_modules/react-native-draggable-flatlist/src/context/refContext.tsx +index ea21575..66c5eed 100644 +--- a/node_modules/react-native-draggable-flatlist/src/context/refContext.tsx ++++ b/node_modules/react-native-draggable-flatlist/src/context/refContext.tsx +@@ -1,14 +1,14 @@ + import React, { useContext } from "react"; + import { useMemo, useRef } from "react"; + import { FlatList } from "react-native-gesture-handler"; +-import Animated, { WithSpringConfig } from "react-native-reanimated"; ++import Animated, { type SharedValue, useSharedValue, WithSpringConfig } from "react-native-reanimated"; + import { DEFAULT_PROPS } from "../constants"; + import { useProps } from "./propsContext"; + import { CellData, DraggableFlatListProps } from "../types"; + + type RefContextValue = { + propsRef: React.MutableRefObject>; +- animationConfigRef: React.MutableRefObject; ++ animationConfigRef: SharedValue; + cellDataRef: React.MutableRefObject>; + keyToIndexRef: React.MutableRefObject>; + containerRef: React.RefObject; +@@ -54,8 +54,8 @@ function useSetupRefs({ + ...DEFAULT_PROPS.animationConfig, + ...animationConfig, + } as WithSpringConfig; +- const animationConfigRef = useRef(animConfig); +- animationConfigRef.current = animConfig; ++ const animationConfigRef = useSharedValue(animConfig); ++ animationConfigRef.value = animConfig; + + const cellDataRef = useRef(new Map()); + const keyToIndexRef = useRef(new Map()); +diff --git a/node_modules/react-native-draggable-flatlist/src/hooks/useCellTranslate.tsx b/node_modules/react-native-draggable-flatlist/src/hooks/useCellTranslate.tsx +index ce4ab68..efea240 100644 +--- a/node_modules/react-native-draggable-flatlist/src/hooks/useCellTranslate.tsx ++++ b/node_modules/react-native-draggable-flatlist/src/hooks/useCellTranslate.tsx +@@ -101,7 +101,7 @@ export function useCellTranslate({ cellIndex, cellSize, cellOffset }: Params) { + ? activeCellSize.value * (isAfterActive ? -1 : 1) + : 0; + +- return withSpring(translationAmt, animationConfigRef.current); ++ return withSpring(translationAmt, animationConfigRef.value); + }, [activeKey, cellIndex]); + + return translate; +diff --git a/node_modules/react-native-draggable-flatlist/src/hooks/useOnCellActiveAnimation.ts b/node_modules/react-native-draggable-flatlist/src/hooks/useOnCellActiveAnimation.ts +index 7c20587..857c7d0 100644 +--- a/node_modules/react-native-draggable-flatlist/src/hooks/useOnCellActiveAnimation.ts ++++ b/node_modules/react-native-draggable-flatlist/src/hooks/useOnCellActiveAnimation.ts +@@ -1,8 +1,9 @@ +-import { useRef } from "react"; +-import Animated, { ++ ++import { + useDerivedValue, + withSpring, + WithSpringConfig, ++ useSharedValue, + } from "react-native-reanimated"; + import { DEFAULT_ANIMATION_CONFIG } from "../constants"; + import { useAnimatedValues } from "../context/animatedValueContext"; +@@ -15,8 +16,8 @@ type Params = { + export function useOnCellActiveAnimation( + { animationConfig }: Params = { animationConfig: {} } + ) { +- const animationConfigRef = useRef(animationConfig); +- animationConfigRef.current = animationConfig; ++ const animationConfigRef = useSharedValue(animationConfig); ++ animationConfigRef.value = animationConfig; + + const isActive = useIsActive(); + +@@ -26,7 +27,7 @@ export function useOnCellActiveAnimation( + const toVal = isActive && isTouchActiveNative.value ? 1 : 0; + return withSpring(toVal, { + ...DEFAULT_ANIMATION_CONFIG, +- ...animationConfigRef.current, ++ ...animationConfigRef.value, + }); + }, [isActive]); + diff --git a/src/App.tsx b/src/App.tsx index 35254fa29b2a..177cc00c7dee 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -50,9 +50,6 @@ LogBox.ignoreLogs([ // the timer is lost. Currently Expensify is using a 30 minutes interval to refresh personal details. // More details here: https://git.io/JJYeb 'Setting a timer for a long period of time', - // We silence this warning for now and will address all the places where it happens separately. - // Then we can remove this line so the problem does not occur in the future. - '[Reanimated] Tried to modify key `current`', ]); const fill = {flex: 1}; diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 7fcb675dc191..cb8bf2fdb5d3 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -849,7 +849,7 @@ type OnyxValuesMapping = { // ONYXKEYS.NVP_TRYNEWDOT is HybridApp onboarding data [ONYXKEYS.NVP_TRYNEWDOT]: OnyxTypes.TryNewDot; - [ONYXKEYS.SAVED_SEARCHES]: OnyxTypes.SaveSearch[]; + [ONYXKEYS.SAVED_SEARCHES]: OnyxTypes.SaveSearch; [ONYXKEYS.RECENTLY_USED_CURRENCIES]: string[]; [ONYXKEYS.ACTIVE_CLIENTS]: string[]; [ONYXKEYS.DEVICE_ID]: string; diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 51d05870da2e..dfcb42d3c4fe 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -59,11 +59,9 @@ const ROUTES = { SEARCH_ADVANCED_FILTERS_IN: 'search/filters/in', SEARCH_REPORT: { route: 'search/view/:reportID/:reportActionID?', - getRoute: (reportID: string, reportActionID?: string) => { - if (reportActionID) { - return `search/view/${reportID}/${reportActionID}` as const; - } - return `search/view/${reportID}` as const; + getRoute: ({reportID, reportActionID, backTo}: {reportID: string; reportActionID?: string; backTo?: string}) => { + const baseRoute = reportActionID ? (`search/view/${reportID}/${reportActionID}` as const) : (`search/view/${reportID}` as const); + return getUrlWithBackToParam(baseRoute, backTo); }, }, TRANSACTION_HOLD_REASON_RHP: 'search/hold', @@ -1579,6 +1577,12 @@ type Route = { type RoutesValidationError = 'Error: One or more routes defined within `ROUTES` have not correctly used `as const` in their `getRoute` function return value.'; +/** + * Represents all routes in the app as a union of literal strings. + * + * If TS throws on this line, it implies that one or more routes defined within `ROUTES` have not correctly used + * `as const` in their `getRoute` function return value. + */ // eslint-disable-next-line @typescript-eslint/no-unused-vars type RouteIsPlainString = AssertTypesNotEqual; diff --git a/src/components/AmountForm.tsx b/src/components/AmountForm.tsx index 564077387d5b..4848577bdea0 100644 --- a/src/components/AmountForm.tsx +++ b/src/components/AmountForm.tsx @@ -48,6 +48,9 @@ type AmountFormProps = { /** Whether the form should use a standard TextInput as a base */ displayAsTextInput?: boolean; + + /** Number of decimals to display */ + fixedDecimals?: number; } & Pick & Pick; @@ -75,6 +78,7 @@ function AmountForm( displayAsTextInput = false, isCurrencyPressable = true, label, + fixedDecimals, ...rest }: AmountFormProps, forwardedRef: ForwardedRef, @@ -84,7 +88,7 @@ function AmountForm( const textInput = useRef(null); - const decimals = CurrencyUtils.getCurrencyDecimals(currency) + extraDecimals; + const decimals = fixedDecimals ?? CurrencyUtils.getCurrencyDecimals(currency) + extraDecimals; const currentAmount = useMemo(() => (typeof amount === 'string' ? amount : ''), [amount]); const [shouldUpdateSelection, setShouldUpdateSelection] = useState(true); diff --git a/src/components/Form/FormProvider.tsx b/src/components/Form/FormProvider.tsx index db52c45751b7..80f52c8053da 100644 --- a/src/components/Form/FormProvider.tsx +++ b/src/components/Form/FormProvider.tsx @@ -3,17 +3,15 @@ import lodashIsEqual from 'lodash/isEqual'; import type {ForwardedRef, MutableRefObject, ReactNode, RefAttributes} from 'react'; import React, {createRef, forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react'; import type {NativeSyntheticEvent, StyleProp, TextInputSubmitEditingEventData, ViewStyle} from 'react-native'; -import type {OnyxEntry} from 'react-native-onyx'; -import {withOnyx} from 'react-native-onyx'; +import {useOnyx} from 'react-native-onyx'; import useLocalize from '@hooks/useLocalize'; import * as ValidationUtils from '@libs/ValidationUtils'; import Visibility from '@libs/Visibility'; import * as FormActions from '@userActions/FormActions'; import CONST from '@src/CONST'; -import type {OnyxFormKey} from '@src/ONYXKEYS'; +import type {OnyxFormDraftKey, OnyxFormKey} from '@src/ONYXKEYS'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Form} from '@src/types/form'; -import type {Network} from '@src/types/onyx'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import type {RegisterInput} from './FormContext'; import FormContext from './FormContext'; @@ -41,46 +39,34 @@ function getInitialValueByType(valueType?: ValueTypeKey): InitialDefaultValue { } } -type FormProviderOnyxProps = { - /** Contains the form state that must be accessed outside the component */ - formState: OnyxEntry
; +type FormProviderProps = FormProps & { + /** Children to render. */ + children: ((props: {inputValues: FormOnyxValues}) => ReactNode) | ReactNode; - /** Contains draft values for each input in the form */ - draftValues: OnyxEntry; + /** Callback to validate the form */ + validate?: (values: FormOnyxValues) => FormInputErrors; - /** Information about the network */ - network: OnyxEntry; -}; - -type FormProviderProps = FormProviderOnyxProps & - FormProps & { - /** Children to render. */ - children: ((props: {inputValues: FormOnyxValues}) => ReactNode) | ReactNode; - - /** Callback to validate the form */ - validate?: (values: FormOnyxValues) => FormInputErrors; + /** Should validate function be called when input loose focus */ + shouldValidateOnBlur?: boolean; - /** Should validate function be called when input loose focus */ - shouldValidateOnBlur?: boolean; + /** Should validate function be called when the value of the input is changed */ + shouldValidateOnChange?: boolean; - /** Should validate function be called when the value of the input is changed */ - shouldValidateOnChange?: boolean; + /** Whether to remove invisible characters from strings before validation and submission */ + shouldTrimValues?: boolean; - /** Whether to remove invisible characters from strings before validation and submission */ - shouldTrimValues?: boolean; + /** Styles that will be applied to the submit button only */ + submitButtonStyles?: StyleProp; - /** Styles that will be applied to the submit button only */ - submitButtonStyles?: StyleProp; + /** Whether to apply flex to the submit button */ + submitFlexEnabled?: boolean; - /** Whether to apply flex to the submit button */ - submitFlexEnabled?: boolean; + /** Whether button is disabled */ + isSubmitDisabled?: boolean; - /** Whether button is disabled */ - isSubmitDisabled?: boolean; - - /** Whether HTML is allowed in form inputs */ - allowHTML?: boolean; - }; + /** Whether HTML is allowed in form inputs */ + allowHTML?: boolean; +}; function FormProvider( { @@ -89,10 +75,7 @@ function FormProvider( shouldValidateOnBlur = true, shouldValidateOnChange = true, children, - formState, - network, enabledWhenOffline = false, - draftValues, onSubmit, shouldTrimValues = true, allowHTML = false, @@ -100,6 +83,9 @@ function FormProvider( }: FormProviderProps, forwardedRef: ForwardedRef, ) { + const [network] = useOnyx(ONYXKEYS.NETWORK); + const [formState] = useOnyx(`${formID}`); + const [draftValues] = useOnyx(`${formID}Draft`); const {preferredLocale, translate} = useLocalize(); const inputRefs = useRef({}); const touchedInputs = useRef>({}); @@ -404,19 +390,6 @@ function FormProvider( FormProvider.displayName = 'Form'; -export default withOnyx({ - network: { - key: ONYXKEYS.NETWORK, - }, - // withOnyx typings are not able to handle such generic cases like this one, since it's a generic component we need to cast the keys to any - formState: { - // eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-explicit-any - key: ({formID}) => formID as any, - }, - draftValues: { - // eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-explicit-any - key: (props) => `${props.formID}Draft` as any, - }, -})(forwardRef(FormProvider)) as (props: Omit & RefAttributes, keyof FormProviderOnyxProps>) => ReactNode; +export default forwardRef(FormProvider) as (props: FormProviderProps & RefAttributes) => ReactNode; export type {FormProviderProps}; diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index b1340531c7f2..12da12b8b15d 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -414,12 +414,12 @@ function MoneyReportHeader({policy, report: moneyRequestReport, transactionThrea /> setIsDeleteRequestModalVisible(false)} onModalHide={() => ReportUtils.navigateBackAfterDeleteTransaction(navigateBackToAfterDelete.current)} - prompt={translate('iou.deleteConfirmation')} + prompt={translate('iou.deleteConfirmation', {count: 1})} confirmText={translate('common.delete')} cancelText={translate('common.cancel')} danger diff --git a/src/components/PopoverMenu.tsx b/src/components/PopoverMenu.tsx index 3b074bf772e6..e3a04903f5ca 100644 --- a/src/components/PopoverMenu.tsx +++ b/src/components/PopoverMenu.tsx @@ -14,6 +14,7 @@ import * as Browser from '@libs/Browser'; import * as Modal from '@userActions/Modal'; import CONST from '@src/CONST'; import type {AnchorPosition} from '@src/styles'; +import type {PendingAction} from '@src/types/onyx/OnyxCommon'; import type AnchorAlignment from '@src/types/utils/AnchorAlignment'; import FocusableMenuItem from './FocusableMenuItem'; import FocusTrapForModal from './FocusTrap/FocusTrapForModal'; @@ -21,6 +22,7 @@ import * as Expensicons from './Icon/Expensicons'; import type {MenuItemProps} from './MenuItem'; import MenuItem from './MenuItem'; import type BaseModalProps from './Modal/types'; +import OfflineWithFeedback from './OfflineWithFeedback'; import PopoverWithMeasuredContent from './PopoverWithMeasuredContent'; import ScrollView from './ScrollView'; import Text from './Text'; @@ -48,6 +50,8 @@ type PopoverMenuItem = MenuItemProps & { /** Whether to close all modals */ shouldCloseAllModals?: boolean; + + pendingAction?: PendingAction; }; type PopoverModalProps = Pick; @@ -262,49 +266,53 @@ function PopoverMenu({ {renderHeaderText()} {enteredSubMenuIndexes.length > 0 && renderBackButtonItem()} {currentMenuItems.map((item, menuIndex) => ( - selectItem(menuIndex)} - focused={focusedIndex === menuIndex} - displayInDefaultIconColor={item.displayInDefaultIconColor} - shouldShowRightIcon={item.shouldShowRightIcon} - shouldShowRightComponent={item.shouldShowRightComponent} - iconRight={item.iconRight} - rightComponent={item.rightComponent} - shouldPutLeftPaddingWhenNoIcon={item.shouldPutLeftPaddingWhenNoIcon} - label={item.label} - style={{backgroundColor: item.isSelected ? theme.activeComponentBG : undefined}} - isLabelHoverable={item.isLabelHoverable} - floatRightAvatars={item.floatRightAvatars} - floatRightAvatarSize={item.floatRightAvatarSize} - shouldShowSubscriptRightAvatar={item.shouldShowSubscriptRightAvatar} - disabled={item.disabled} - onFocus={() => setFocusedIndex(menuIndex)} - success={item.success} - containerStyle={item.containerStyle} - shouldRenderTooltip={item.shouldRenderTooltip} - tooltipAnchorAlignment={item.tooltipAnchorAlignment} - tooltipShiftHorizontal={item.tooltipShiftHorizontal} - tooltipShiftVertical={item.tooltipShiftVertical} - tooltipWrapperStyle={item.tooltipWrapperStyle} - renderTooltipContent={item.renderTooltipContent} - numberOfLinesTitle={item.numberOfLinesTitle} - interactive={item.interactive} - isSelected={item.isSelected} - badgeText={item.badgeText} - /> + pendingAction={item.pendingAction} + > + selectItem(menuIndex)} + focused={focusedIndex === menuIndex} + displayInDefaultIconColor={item.displayInDefaultIconColor} + shouldShowRightIcon={item.shouldShowRightIcon} + shouldShowRightComponent={item.shouldShowRightComponent} + iconRight={item.iconRight} + rightComponent={item.rightComponent} + shouldPutLeftPaddingWhenNoIcon={item.shouldPutLeftPaddingWhenNoIcon} + label={item.label} + style={{backgroundColor: item.isSelected ? theme.activeComponentBG : undefined}} + isLabelHoverable={item.isLabelHoverable} + floatRightAvatars={item.floatRightAvatars} + floatRightAvatarSize={item.floatRightAvatarSize} + shouldShowSubscriptRightAvatar={item.shouldShowSubscriptRightAvatar} + disabled={item.disabled} + onFocus={() => setFocusedIndex(menuIndex)} + success={item.success} + containerStyle={item.containerStyle} + shouldRenderTooltip={item.shouldRenderTooltip} + tooltipAnchorAlignment={item.tooltipAnchorAlignment} + tooltipShiftHorizontal={item.tooltipShiftHorizontal} + tooltipShiftVertical={item.tooltipShiftVertical} + tooltipWrapperStyle={item.tooltipWrapperStyle} + renderTooltipContent={item.renderTooltipContent} + numberOfLinesTitle={item.numberOfLinesTitle} + interactive={item.interactive} + isSelected={item.isSelected} + badgeText={item.badgeText} + /> + ))} diff --git a/src/components/ProcessMoneyReportHoldMenu.tsx b/src/components/ProcessMoneyReportHoldMenu.tsx index 8cbbd1199b33..da572e4b1a79 100644 --- a/src/components/ProcessMoneyReportHoldMenu.tsx +++ b/src/components/ProcessMoneyReportHoldMenu.tsx @@ -75,7 +75,7 @@ function ProcessMoneyReportHoldMenu({ if (nonHeldAmount) { return translate(isApprove ? 'iou.confirmApprovalAmount' : 'iou.confirmPayAmount'); } - return translate(isApprove ? 'iou.confirmApprovalAllHoldAmount' : 'iou.confirmPayAllHoldAmount', {transactionCount}); + return translate(isApprove ? 'iou.confirmApprovalAllHoldAmount' : 'iou.confirmPayAllHoldAmount', {count: transactionCount}); }, [nonHeldAmount, transactionCount, translate, isApprove]); return ( diff --git a/src/components/PromotedActionsBar.tsx b/src/components/PromotedActionsBar.tsx index 4b5977fde19b..e6ce3080ee0a 100644 --- a/src/components/PromotedActionsBar.tsx +++ b/src/components/PromotedActionsBar.tsx @@ -100,7 +100,7 @@ const PromotedActions = { return; } - ReportUtils.changeMoneyRequestHoldStatus(reportAction, ROUTES.SEARCH_REPORT.getRoute(targetedReportID), currentSearchHash); + ReportUtils.changeMoneyRequestHoldStatus(reportAction, ROUTES.SEARCH_REPORT.getRoute({reportID: targetedReportID}), currentSearchHash); }, }), } satisfies PromotedActionsType; diff --git a/src/components/ReportActionItem/ReportPreview.tsx b/src/components/ReportActionItem/ReportPreview.tsx index 50ab8e9ee08c..2dfc44b4d2fc 100644 --- a/src/components/ReportActionItem/ReportPreview.tsx +++ b/src/components/ReportActionItem/ReportPreview.tsx @@ -391,9 +391,9 @@ function ReportPreview({ } return { supportText: translate('iou.expenseCount', { - count: numberOfRequests, scanningReceipts: numberOfScanningReceipts, pendingReceipts: numberOfPendingRequests, + count: numberOfRequests, }), }; }, [formattedMerchant, formattedDescription, moneyRequestComment, translate, numberOfRequests, numberOfScanningReceipts, numberOfPendingRequests]); diff --git a/src/components/Search/SearchPageHeader.tsx b/src/components/Search/SearchPageHeader.tsx index 023e7315e115..2580298ac3ac 100644 --- a/src/components/Search/SearchPageHeader.tsx +++ b/src/components/Search/SearchPageHeader.tsx @@ -317,7 +317,7 @@ function SearchPageHeader({queryJSON, hash, onSelectDeleteOption, setOfflineModa shouldAlwaysShowDropdownMenu pressOnEnter buttonSize={CONST.DROPDOWN_BUTTON_SIZE.MEDIUM} - customText={translate('workspace.common.selected', {selectedNumber: selectedTransactionsKeys.length})} + customText={translate('workspace.common.selected', {count: selectedTransactionsKeys.length})} options={headerButtonsOptions} isSplitButton={false} shouldUseStyleUtilityForAnchorPosition diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index d1080da19932..b415d91b7ab4 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -294,13 +294,15 @@ function Search({queryJSON}: SearchProps) { SearchActions.createTransactionThread(hash, item.transactionID, reportID, item.moneyRequestReportActionID); } + const backTo = Navigation.getActiveRoute(); + if (SearchUtils.isReportActionListItemType(item)) { const reportActionID = item.reportActionID; - Navigation.navigate(ROUTES.SEARCH_REPORT.getRoute(reportID, reportActionID)); + Navigation.navigate(ROUTES.SEARCH_REPORT.getRoute({reportID, reportActionID, backTo})); return; } - Navigation.navigate(ROUTES.SEARCH_REPORT.getRoute(reportID)); + Navigation.navigate(ROUTES.SEARCH_REPORT.getRoute({reportID, backTo})); }; const fetchMoreResults = () => { diff --git a/src/components/SelectionList/Search/ReportListItem.tsx b/src/components/SelectionList/Search/ReportListItem.tsx index a0b96547bcd8..2c23c3ede4c5 100644 --- a/src/components/SelectionList/Search/ReportListItem.tsx +++ b/src/components/SelectionList/Search/ReportListItem.tsx @@ -84,7 +84,9 @@ function ReportListItem({ }; const openReportInRHP = (transactionItem: TransactionListItemType) => { - Navigation.navigate(ROUTES.SEARCH_REPORT.getRoute(transactionItem.transactionThreadReportID)); + const backTo = Navigation.getActiveRoute(); + + Navigation.navigate(ROUTES.SEARCH_REPORT.getRoute({reportID: transactionItem.transactionThreadReportID, backTo})); }; if (!reportItem?.reportName && reportItem.transactions.length > 1) { diff --git a/src/components/SelectionList/types.ts b/src/components/SelectionList/types.ts index 14c83ef25ed4..b0d657b202c6 100644 --- a/src/components/SelectionList/types.ts +++ b/src/components/SelectionList/types.ts @@ -233,6 +233,7 @@ type ReportListItemType = ListItem & /** The personal details of the user paying the request */ to: SearchPersonalDetails; + /** List of transactions that belong to this report */ transactions: TransactionListItemType[]; }; diff --git a/src/components/Tooltip/BaseGenericTooltip/index.tsx b/src/components/Tooltip/BaseGenericTooltip/index.tsx index e6c319d96585..4477c991e3ac 100644 --- a/src/components/Tooltip/BaseGenericTooltip/index.tsx +++ b/src/components/Tooltip/BaseGenericTooltip/index.tsx @@ -50,8 +50,17 @@ function BaseGenericTooltip({ useLayoutEffect(() => { // Calculate the tooltip width and height before the browser repaints the screen to prevent flicker // because of the late update of the width and the height from onLayout. + const rootWrapperStyle = rootWrapper?.current?.style; + const isScaled = rootWrapperStyle?.transform === 'scale(0)'; + if (isScaled) { + // Temporarily reset the scale caused by animation to get the untransformed size. + rootWrapperStyle.transform = 'scale(1)'; + } setContentMeasuredWidth(contentRef.current?.getBoundingClientRect().width); setWrapperMeasuredHeight(rootWrapper.current?.getBoundingClientRect().height); + if (isScaled) { + rootWrapperStyle.transform = 'scale(0)'; + } }, []); const {animationStyle, rootWrapperStyle, textStyle, pointerWrapperStyle, pointerStyle} = useMemo( diff --git a/src/languages/en.ts b/src/languages/en.ts index 6a90b1f8302a..6d579a2af2df 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -1,4 +1,4 @@ -import {CONST as COMMON_CONST, Str} from 'expensify-common'; +import {CONST as COMMON_CONST} from 'expensify-common'; import startCase from 'lodash/startCase'; import CONST from '@src/CONST'; import type {Country} from '@src/CONST'; @@ -8,7 +8,6 @@ import type { AddEmployeeParams, AddressLineParams, AdminCanceledRequestParams, - AgeParams, AlreadySignedInParams, ApprovalWorkflowErrorParams, ApprovedAmountParams, @@ -42,7 +41,6 @@ import type { CharacterLengthLimitParams, CharacterLimitParams, CompanyCardFeedNameParams, - ConfirmHoldExpenseParams, ConfirmThatParams, ConnectionNameParams, ConnectionParams, @@ -57,10 +55,7 @@ import type { DelegatorParams, DeleteActionParams, DeleteConfirmationParams, - DeleteExpenseTranslationParams, DidSplitAmountMessageParams, - DimensionsCountParams, - DistanceRateOperationsParams, EditActionParams, ElectronicFundsParams, EnterMagicCodeParams, @@ -133,7 +128,6 @@ import type { RoomNameReservedErrorParams, RoomRenamedToParams, SecondaryLoginParams, - SelectedNumberParams, SetTheDistanceMerchantParams, SetTheRequestParams, SettledAfterAddedBankAccountParams, @@ -846,7 +840,10 @@ const translations = { receiptScanning: 'Receipt scanning...', receiptScanInProgress: 'Receipt scan in progress', receiptScanInProgressDescription: 'Receipt scan in progress. Check back later or enter the details now.', - receiptIssuesFound: ({count}: DistanceRateOperationsParams) => `${count === 1 ? 'Issue' : 'Issues'} found`, + receiptIssuesFound: () => ({ + one: 'Issue found', + other: 'Issues found', + }), fieldPending: 'Pending...', defaultRate: 'Default rate', receiptMissingDetails: 'Receipt missing details', @@ -863,19 +860,27 @@ const translations = { yourCompanyWebsiteNote: "If you don't have a website, you can provide your company's LinkedIn or social media profile instead.", invalidDomainError: 'You have entered an invalid domain. To continue, please enter a valid domain.', publicDomainError: 'You have entered a public domain. To continue, please enter a private domain.', - expenseCount: ({count, scanningReceipts = 0, pendingReceipts = 0}: RequestCountParams) => { - const expenseText = `${count} ${Str.pluralize('expense', 'expenses', count)}`; - const statusText = []; + expenseCount: ({scanningReceipts = 0, pendingReceipts = 0}: RequestCountParams) => { + const statusText: string[] = []; if (scanningReceipts > 0) { statusText.push(`${scanningReceipts} scanning`); } if (pendingReceipts > 0) { statusText.push(`${pendingReceipts} pending`); } - return statusText.length > 0 ? `${expenseText} (${statusText.join(', ')})` : expenseText; - }, - deleteExpense: ({count}: DeleteExpenseTranslationParams = {count: 1}) => `Delete ${Str.pluralize('expense', 'expenses', count)}`, - deleteConfirmation: ({count}: DeleteExpenseTranslationParams = {count: 1}) => `Are you sure that you want to delete ${Str.pluralize('this expense', 'these expenses', count)}?`, + return { + one: statusText.length > 0 ? `1 expense (${statusText.join(', ')})` : `1 expense`, + other: (count: number) => (statusText.length > 0 ? `${count} expenses (${statusText.join(', ')})` : `${count} expenses`), + }; + }, + deleteExpense: () => ({ + one: 'Delete expense', + other: 'Delete expenses', + }), + deleteConfirmation: () => ({ + one: 'Are you sure that you want to delete this expense?', + other: 'Are you sure that you want to delete these expenses?', + }), settledExpensify: 'Paid', settledElsewhere: 'Paid elsewhere', individual: 'Individual', @@ -976,12 +981,16 @@ const translations = { keepAll: 'Keep all', confirmApprove: 'Confirm approval amount', confirmApprovalAmount: 'Approve only compliant expenses, or approve the entire report.', - confirmApprovalAllHoldAmount: ({transactionCount}: ConfirmHoldExpenseParams) => - `${Str.pluralize('This expense is', 'These expenses are', transactionCount)} on hold. Do you want to approve anyway?`, + confirmApprovalAllHoldAmount: () => ({ + one: 'This expense is on hold. Do you want to approve anyway?', + other: 'These expenses are on hold. Do you want to approve anyway?', + }), confirmPay: 'Confirm payment amount', confirmPayAmount: "Pay what's not on hold, or pay the entire report.", - confirmPayAllHoldAmount: ({transactionCount}: ConfirmHoldExpenseParams) => - `${Str.pluralize('This expense is', 'These expenses are', transactionCount)} on hold. Do you want to pay anyway?`, + confirmPayAllHoldAmount: () => ({ + one: 'This expense is on hold. Do you want to pay anyway?', + other: 'These expenses are on hold. Do you want to pay anyway?', + }), payOnly: 'Pay only', approveOnly: 'Approve only', holdEducationalTitle: 'This expense is on', @@ -2273,7 +2282,10 @@ const translations = { testTransactions: 'Test transactions', issueAndManageCards: 'Issue and manage cards', reconcileCards: 'Reconcile cards', - selected: ({selectedNumber}: SelectedNumberParams) => `${selectedNumber} selected`, + selected: () => ({ + one: '1 selected', + other: (count: number) => `${count} selected`, + }), settlementFrequency: 'Settlement frequency', deleteConfirmation: 'Are you sure you want to delete this workspace?', unavailable: 'Unavailable workspace', @@ -2874,7 +2886,10 @@ const translations = { addAUserDefinedDimension: 'Add a user-defined dimension', detailedInstructionsLink: 'View detailed instructions', detailedInstructionsRestOfSentence: ' on adding user-defined dimensions.', - userDimensionsAdded: ({dimensionsCount}: DimensionsCountParams) => `${dimensionsCount} ${Str.pluralize('UDD', `UDDs`, dimensionsCount)} added`, + userDimensionsAdded: () => ({ + one: '1 UDD added', + other: (count: number) => `${count} UDDs added`, + }), mappingTitle: ({mappingName}: IntacctMappingTitleParams) => { switch (mappingName) { case CONST.SAGE_INTACCT_CONFIG.MAPPINGS.DEPARTMENTS: @@ -3668,8 +3683,14 @@ const translations = { one: 'Delete rate', other: 'Delete rates', }), - enableRates: ({count}: DistanceRateOperationsParams) => `Enable ${Str.pluralize('rate', 'rates', count)}`, - disableRates: ({count}: DistanceRateOperationsParams) => `Disable ${Str.pluralize('rate', 'rates', count)}`, + enableRates: () => ({ + one: 'Enable rate', + other: 'Enable rates', + }), + disableRates: () => ({ + one: 'Disable rate', + other: 'Disable rates', + }), enableRate: 'Enable rate', status: 'Status', unit: 'Unit', @@ -3677,7 +3698,10 @@ const translations = { changePromptMessage: ' to make that change.', defaultCategory: 'Default category', deleteDistanceRate: 'Delete distance rate', - areYouSureDelete: ({count}: DistanceRateOperationsParams) => `Are you sure you want to delete ${Str.pluralize('this rate', 'these rates', count)}?`, + areYouSureDelete: () => ({ + one: 'Are you sure you want to delete this rate?', + other: 'Are you sure you want to delete these rates?', + }), }, editor: { descriptionInputLabel: 'Description', @@ -3855,7 +3879,10 @@ const translations = { maxAge: 'Max age', maxExpenseAge: 'Max expense age', maxExpenseAgeDescription: 'Flag spend older than a specific number of days.', - maxExpenseAgeDays: ({age}: AgeParams) => `${age} ${Str.pluralize('day', 'days', age)}`, + maxExpenseAgeDays: () => ({ + one: '1 day', + other: (count: number) => `${count} days`, + }), billableDefault: 'Billable default', billableDefaultDescription: 'Choose whether cash and credit card expenses should be billable by default. Billable expenses are enabled or disabled in', billable: 'Billable', @@ -3994,9 +4021,10 @@ const translations = { } else if (submittersNames.length > 2) { joinedNames = `${submittersNames.slice(0, submittersNames.length - 1).join(', ')} and ${submittersNames[submittersNames.length - 1]}`; } - const workflowWord = Str.pluralize('workflow', 'workflows', submittersNames.length); - const chatWord = Str.pluralize('chat', 'chats', submittersNames.length); - return `removed you from ${joinedNames}'s approval ${workflowWord} and workspace ${chatWord}. Previously submitted reports will remain available for approval in your Inbox.`; + return { + one: `removed you from ${joinedNames}'s approval workflow and workspace chat. Previously submitted reports will remain available for approval in your Inbox.`, + other: `removed you from ${joinedNames}'s approval workflows and workspace chats. Previously submitted reports will remain available for approval in your Inbox.`, + }; }, }, roomMembersPage: { diff --git a/src/languages/es.ts b/src/languages/es.ts index 8d8b33ca57dd..cb19b091b058 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -1,4 +1,3 @@ -import {Str} from 'expensify-common'; import CONST from '@src/CONST'; import type en from './en'; import type { @@ -7,7 +6,6 @@ import type { AddEmployeeParams, AddressLineParams, AdminCanceledRequestParams, - AgeParams, AlreadySignedInParams, ApprovalWorkflowErrorParams, ApprovedAmountParams, @@ -41,7 +39,6 @@ import type { CharacterLengthLimitParams, CharacterLimitParams, CompanyCardFeedNameParams, - ConfirmHoldExpenseParams, ConfirmThatParams, ConnectionNameParams, ConnectionParams, @@ -56,10 +53,7 @@ import type { DelegatorParams, DeleteActionParams, DeleteConfirmationParams, - DeleteExpenseTranslationParams, DidSplitAmountMessageParams, - DimensionsCountParams, - DistanceRateOperationsParams, EditActionParams, ElectronicFundsParams, EnterMagicCodeParams, @@ -132,7 +126,6 @@ import type { RoomNameReservedErrorParams, RoomRenamedToParams, SecondaryLoginParams, - SelectedNumberParams, SetTheDistanceMerchantParams, SetTheRequestParams, SettledAfterAddedBankAccountParams, @@ -837,7 +830,10 @@ const translations = { pendingMatchWithCreditCardDescription: 'Recibo pendiente de adjuntar con la transacción de la tarjeta. Márcalo como efectivo para cancelar.', markAsCash: 'Marcar como efectivo', routePending: 'Ruta pendiente...', - receiptIssuesFound: ({count}: DistanceRateOperationsParams) => `${count === 1 ? 'Problema encontrado' : 'Problemas encontrados'}`, + receiptIssuesFound: () => ({ + one: 'Problema encontrado', + other: 'Problemas encontrados', + }), fieldPending: 'Pendiente...', receiptScanning: 'Escaneando recibo...', receiptScanInProgress: 'Escaneado de recibo en proceso', @@ -857,19 +853,27 @@ const translations = { yourCompanyWebsiteNote: 'Si no tiene un sitio web, puede proporcionar el perfil de LinkedIn o de las redes sociales de su empresa.', invalidDomainError: 'Ha introducido un dominio no válido. Para continuar, introduzca un dominio válido.', publicDomainError: 'Ha introducido un dominio público. Para continuar, introduzca un dominio privado.', - expenseCount: ({count, scanningReceipts = 0, pendingReceipts = 0}: RequestCountParams) => { - const expenseText = `${count} ${Str.pluralize('gasto', 'gastos', count)}`; - const statusText = []; + expenseCount: ({scanningReceipts = 0, pendingReceipts = 0}: RequestCountParams) => { + const statusText: string[] = []; if (scanningReceipts > 0) { statusText.push(`${scanningReceipts} escaneando`); } if (pendingReceipts > 0) { statusText.push(`${pendingReceipts} pendiente`); } - return statusText.length > 0 ? `${expenseText} (${statusText.join(', ')})` : expenseText; - }, - deleteExpense: ({count}: DeleteExpenseTranslationParams = {count: 1}) => `Eliminar ${Str.pluralize('gasto', 'gastos', count)}`, - deleteConfirmation: ({count}: DeleteExpenseTranslationParams = {count: 1}) => `¿Estás seguro de que quieres eliminar ${Str.pluralize('esta solicitud', 'estas solicitudes', count)}?`, + return { + one: statusText.length > 0 ? `1 gasto (${statusText.join(', ')})` : `1 gasto`, + other: (count: number) => (statusText.length > 0 ? `${count} gastos (${statusText.join(', ')})` : `${count} gastos`), + }; + }, + deleteExpense: () => ({ + one: 'Eliminar gasto', + other: 'Eliminar gastos', + }), + deleteConfirmation: () => ({ + one: '¿Estás seguro de que quieres eliminar esta solicitud?', + other: '¿Estás seguro de que quieres eliminar estas solicitudes?', + }), settledExpensify: 'Pagado', settledElsewhere: 'Pagado de otra forma', individual: 'Individual', @@ -969,20 +973,16 @@ const translations = { keepAll: 'Mantener todos', confirmApprove: 'Confirmar importe a aprobar', confirmApprovalAmount: 'Aprueba sólo los gastos conformes, o aprueba todo el informe.', - confirmApprovalAllHoldAmount: ({transactionCount}: ConfirmHoldExpenseParams) => - `${Str.pluralize('Este gasto está bloqueado', 'Estos gastos están bloqueados', transactionCount)}. ¿Quieres ${Str.pluralize( - 'aprobar', - 'aprobarlos', - transactionCount, - )} de todos modos?`, + confirmApprovalAllHoldAmount: () => ({ + one: 'Este gasto está bloqueado. ¿Quieres aprobarlo de todos modos?', + other: 'Estos gastos están bloqueados. ¿Quieres aprobarlos de todos modos?', + }), confirmPay: 'Confirmar importe de pago', confirmPayAmount: 'Paga lo que no está bloqueado, o paga el informe completo.', - confirmPayAllHoldAmount: ({transactionCount}: ConfirmHoldExpenseParams) => - `${Str.pluralize('Este gasto está bloqueado', 'Estos gastos están bloqueados', transactionCount)}. ¿Quieres ${Str.pluralize( - 'pagar', - 'pagarlo', - transactionCount, - )} de todos modos?`, + confirmPayAllHoldAmount: () => ({ + one: 'Este gasto está bloqueado. ¿Quieres pagarlo de todos modos?', + other: 'Estos gastos están bloqueados. ¿Quieres pagarlos de todos modos?', + }), payOnly: 'Solo pagar', approveOnly: 'Solo aprobar', hold: 'Bloquear', @@ -2302,7 +2302,10 @@ const translations = { testTransactions: 'Transacciones de prueba', issueAndManageCards: 'Emitir y gestionar tarjetas', reconcileCards: 'Reconciliar tarjetas', - selected: ({selectedNumber}: SelectedNumberParams) => `${selectedNumber} seleccionados`, + selected: () => ({ + one: '1 seleccionado', + other: (count: number) => `${count} seleccionados`, + }), settlementFrequency: 'Frecuencia de liquidación', deleteConfirmation: '¿Estás seguro de que quieres eliminar este espacio de trabajo?', unavailable: 'Espacio de trabajo no disponible', @@ -2918,7 +2921,10 @@ const translations = { addAUserDefinedDimension: 'Añadir una dimensión definida por el usuario', detailedInstructionsLink: 'Ver instrucciones detalladas', detailedInstructionsRestOfSentence: ' para añadir dimensiones definidas por el usuario.', - userDimensionsAdded: ({dimensionsCount}: DimensionsCountParams) => `${dimensionsCount} ${Str.pluralize('UDD', `UDDs`, dimensionsCount)} añadido`, + userDimensionsAdded: () => ({ + one: '1 UDD añadido', + other: (count: number) => `${count} UDDs añadido`, + }), mappingTitle: ({mappingName}: IntacctMappingTitleParams) => { switch (mappingName) { case CONST.SAGE_INTACCT_CONFIG.MAPPINGS.DEPARTMENTS: @@ -3718,8 +3724,14 @@ const translations = { one: 'Eliminar tasa', other: 'Eliminar tasas', }), - enableRates: ({count}: DistanceRateOperationsParams) => `Activar ${Str.pluralize('tasa', 'tasas', count)}`, - disableRates: ({count}: DistanceRateOperationsParams) => `Desactivar ${Str.pluralize('tasa', 'tasas', count)}`, + enableRates: () => ({ + one: 'Activar tasa', + other: 'Activar tasas', + }), + disableRates: () => ({ + one: 'Desactivar tasa', + other: 'Desactivar tasas', + }), enableRate: 'Activar tasa', status: 'Estado', unit: 'Unidad', @@ -3727,7 +3739,10 @@ const translations = { changePromptMessage: ' para hacer ese cambio.', defaultCategory: 'Categoría predeterminada', deleteDistanceRate: 'Eliminar tasa de distancia', - areYouSureDelete: ({count}: DistanceRateOperationsParams) => `¿Estás seguro de que quieres eliminar ${Str.pluralize('esta tasa', 'estas tasas', count)}?`, + areYouSureDelete: () => ({ + one: '¿Estás seguro de que quieres eliminar esta tasa?', + other: '¿Estás seguro de que quieres eliminar estas tasas?', + }), }, editor: { nameInputLabel: 'Nombre', @@ -3907,7 +3922,10 @@ const translations = { maxAge: 'Antigüedad máxima', maxExpenseAge: 'Antigüedad máxima de los gastos', maxExpenseAgeDescription: 'Marca los gastos de más de un número determinado de días.', - maxExpenseAgeDays: ({age}: AgeParams) => `${age} ${Str.pluralize('día', 'días', age)}`, + maxExpenseAgeDays: () => ({ + one: '1 día', + other: (count: number) => `${count} días`, + }), billableDefault: 'Valor predeterminado facturable', billableDefaultDescription: 'Elige si los gastos en efectivo y con tarjeta de crédito deben ser facturables por defecto. Los gastos facturables se activan o desactivan en', billable: 'Facturable', @@ -4047,9 +4065,10 @@ const translations = { } else if (submittersNames.length > 2) { joinedNames = `${submittersNames.slice(0, submittersNames.length - 1).join(', ')} y ${submittersNames[submittersNames.length - 1]}`; } - const workflowWord = Str.pluralize('del flujo', 'de los flujos', submittersNames.length); - const chatWord = Str.pluralize('del chat', 'de los chats', submittersNames.length); - return `te eliminó ${workflowWord} de trabajo de aprobaciones y ${chatWord} del espacio de trabajo de ${joinedNames}. Los informes enviados anteriormente seguirán estando disponibles para su aprobación en tu bandeja de entrada.`; + return { + one: `te eliminó del flujo de trabajo de aprobaciones y del chat del espacio de trabajo de ${joinedNames}. Los informes enviados anteriormente seguirán estando disponibles para su aprobación en tu bandeja de entrada.`, + other: `te eliminó de los flujos de trabajo de aprobaciones y de los chats del espacio de trabajo de ${joinedNames}. Los informes enviados anteriormente seguirán estando disponibles para su aprobación en tu bandeja de entrada.`, + }; }, }, roomMembersPage: { diff --git a/src/languages/params.ts b/src/languages/params.ts index 8ed122283064..d51bb2d20e03 100644 --- a/src/languages/params.ts +++ b/src/languages/params.ts @@ -109,7 +109,6 @@ type ReportArchiveReasonsInvoiceReceiverPolicyDeletedParams = { }; type RequestCountParams = { - count: number; scanningReceipts: number; pendingReceipts: number; }; @@ -280,12 +279,8 @@ type LogSizeAndDateParams = {size: number; date: string}; type HeldRequestParams = {comment: string}; -type DistanceRateOperationsParams = {count: number}; - type ReimbursementRateParams = {unit: Unit}; -type ConfirmHoldExpenseParams = {transactionCount: number}; - type ChangeFieldParams = {oldValue?: string; newValue: string; fieldName: string}; type ChangePolicyParams = {fromPolicy: string; toPolicy: string}; @@ -332,10 +327,6 @@ type RemoveMemberPromptParams = { memberName: string; }; -type DeleteExpenseTranslationParams = { - count: number; -}; - type IssueVirtualCardParams = { assignee: string; link: string; @@ -385,8 +376,6 @@ type DisconnectTitleParams = {integration?: ConnectionName} | undefined; type AmountWithCurrencyParams = {amountWithCurrency: string}; -type SelectedNumberParams = {selectedNumber: number}; - type LowerUpperParams = {lower: string; upper: string}; type CategoryNameParams = {categoryName: string}; @@ -455,12 +444,8 @@ type RequiredFieldParams = {fieldName: string}; type ImportFieldParams = {importField: string}; -type DimensionsCountParams = {dimensionsCount: number}; - type IntacctMappingTitleParams = {mappingName: SageIntacctMappingName}; -type AgeParams = {age: number}; - type LastSyncAccountingParams = {relativeDate: string}; type SyncStageNameConnectionsParams = {stage: PolicyConnectionSyncStage}; @@ -571,9 +556,7 @@ export type { ReconciliationWorksParams, LastSyncAccountingParams, SyncStageNameConnectionsParams, - AgeParams, RequiredFieldParams, - DimensionsCountParams, IntacctMappingTitleParams, ImportFieldParams, AssigneeParams, @@ -609,7 +592,6 @@ export type { SecondaryLoginParams, TaxAmountParams, CategoryNameParams, - SelectedNumberParams, AmountWithCurrencyParams, LowerUpperParams, LogSizeAndDateParams, @@ -623,7 +605,6 @@ export type { BeginningOfChatHistoryDomainRoomPartOneParams, CanceledRequestParams, CharacterLimitParams, - ConfirmHoldExpenseParams, ConfirmThatParams, CompanyCardFeedNameParams, DateShouldBeAfterParams, @@ -631,7 +612,6 @@ export type { DeleteActionParams, DeleteConfirmationParams, DidSplitAmountMessageParams, - DistanceRateOperationsParams, EditActionParams, ElectronicFundsParams, EnterMagicCodeParams, @@ -733,7 +713,6 @@ export type { StripePaidParams, UnapprovedParams, RemoveMembersWarningPrompt, - DeleteExpenseTranslationParams, ApprovalWorkflowErrorParams, ConnectionNameParams, LastSyncDateParams, diff --git a/src/libs/API/parameters/AddMembersToWorkspaceParams.ts b/src/libs/API/parameters/AddMembersToWorkspaceParams.ts index 4e96fd07d301..abfed55e2df3 100644 --- a/src/libs/API/parameters/AddMembersToWorkspaceParams.ts +++ b/src/libs/API/parameters/AddMembersToWorkspaceParams.ts @@ -3,6 +3,8 @@ type AddMembersToWorkspaceParams = { welcomeNote: string; policyID: string; reportCreationData?: string; + announceChatReportID?: string; + announceCreatedReportActionID?: string; }; export default AddMembersToWorkspaceParams; diff --git a/src/libs/API/parameters/CreateWorkspaceFromIOUPaymentParams.ts b/src/libs/API/parameters/CreateWorkspaceFromIOUPaymentParams.ts index 761a6c2f5008..a1256f5ad051 100644 --- a/src/libs/API/parameters/CreateWorkspaceFromIOUPaymentParams.ts +++ b/src/libs/API/parameters/CreateWorkspaceFromIOUPaymentParams.ts @@ -1,13 +1,11 @@ type CreateWorkspaceFromIOUPaymentParams = { policyID: string; - announceChatReportID: string; adminsChatReportID: string; expenseChatReportID: string; ownerEmail: string; makeMeAdmin: boolean; policyName: string; type: string; - announceCreatedReportActionID: string; adminsCreatedReportActionID: string; expenseCreatedReportActionID: string; customUnitID: string; diff --git a/src/libs/API/parameters/CreateWorkspaceParams.ts b/src/libs/API/parameters/CreateWorkspaceParams.ts index c86598b48953..18ef4a0e763f 100644 --- a/src/libs/API/parameters/CreateWorkspaceParams.ts +++ b/src/libs/API/parameters/CreateWorkspaceParams.ts @@ -1,13 +1,11 @@ type CreateWorkspaceParams = { policyID: string; - announceChatReportID: string; adminsChatReportID: string; expenseChatReportID: string; ownerEmail: string; makeMeAdmin: boolean; policyName: string; type: string; - announceCreatedReportActionID: string; adminsCreatedReportActionID: string; expenseCreatedReportActionID: string; customUnitID: string; diff --git a/src/libs/CurrencyUtils.ts b/src/libs/CurrencyUtils.ts index c3b80797d750..7d11bd0d61ae 100644 --- a/src/libs/CurrencyUtils.ts +++ b/src/libs/CurrencyUtils.ts @@ -164,7 +164,7 @@ function convertAmountToDisplayString(amount = 0, currency: string = CONST.CURRE return NumberFormatUtils.format(BaseLocaleListener.getPreferredLocale(), convertedAmount, { style: 'currency', currency, - minimumFractionDigits: getCurrencyDecimals(currency) + 1, + minimumFractionDigits: CONST.MAX_TAX_RATE_DECIMAL_PLACES, }); } diff --git a/src/libs/IntlPolyfill/index.android.ts b/src/libs/IntlPolyfill/index.android.ts index 37647b9d2939..e6ab02d15c25 100644 --- a/src/libs/IntlPolyfill/index.android.ts +++ b/src/libs/IntlPolyfill/index.android.ts @@ -11,6 +11,10 @@ const intlPolyfill: IntlPolyfill = () => { require('@formatjs/intl-locale/polyfill-force'); + require('@formatjs/intl-pluralrules/polyfill-force'); + require('@formatjs/intl-pluralrules/locale-data/en'); + require('@formatjs/intl-pluralrules/locale-data/es'); + polyfillListFormat(); }; diff --git a/src/libs/IntlPolyfill/index.ios.ts b/src/libs/IntlPolyfill/index.ios.ts index 4701737c2b1c..ecde57ddd21e 100644 --- a/src/libs/IntlPolyfill/index.ios.ts +++ b/src/libs/IntlPolyfill/index.ios.ts @@ -13,9 +13,12 @@ const intlPolyfill: IntlPolyfill = () => { require('@formatjs/intl-locale/polyfill-force'); + require('@formatjs/intl-pluralrules/polyfill-force'); + require('@formatjs/intl-pluralrules/locale-data/en'); + require('@formatjs/intl-pluralrules/locale-data/es'); + // Required to polyfill NumberFormat on iOS // see: https://github.com/facebook/hermes/issues/1172#issuecomment-1776156538 - require('@formatjs/intl-pluralrules/polyfill-force'); polyfillNumberFormat(); // Required to polyfill DateTimeFormat on iOS diff --git a/src/libs/Navigation/linkingConfig/getAdaptedStateFromPath.ts b/src/libs/Navigation/linkingConfig/getAdaptedStateFromPath.ts index 2c96e5796309..f92b133d719a 100644 --- a/src/libs/Navigation/linkingConfig/getAdaptedStateFromPath.ts +++ b/src/libs/Navigation/linkingConfig/getAdaptedStateFromPath.ts @@ -114,22 +114,22 @@ function getMatchingRootRouteForRHPRoute(route: NavigationPartialRoute): Navigat if (route.params && 'backTo' in route.params && typeof route.params.backTo === 'string') { const stateForBackTo = getStateFromPath(route.params.backTo, config); if (stateForBackTo) { - // eslint-disable-next-line @typescript-eslint/no-shadow - const rhpNavigator = stateForBackTo.routes.find((route) => route.name === NAVIGATORS.RIGHT_MODAL_NAVIGATOR); - - const centralPaneOrFullScreenNavigator = stateForBackTo.routes.find( - // eslint-disable-next-line @typescript-eslint/no-shadow - (route) => isCentralPaneName(route.name) || route.name === NAVIGATORS.FULL_SCREEN_NAVIGATOR, - ); - // If there is rhpNavigator in the state generated for backTo url, we want to get root route matching to this rhp screen. + const rhpNavigator = stateForBackTo.routes.find((rt) => rt.name === NAVIGATORS.RIGHT_MODAL_NAVIGATOR); if (rhpNavigator && rhpNavigator.state) { return getMatchingRootRouteForRHPRoute(findFocusedRoute(stateForBackTo) as NavigationPartialRoute); } - // If we know that backTo targets the root route (central pane or full screen) we want to use it. - if (centralPaneOrFullScreenNavigator && centralPaneOrFullScreenNavigator.state) { - return centralPaneOrFullScreenNavigator as NavigationPartialRoute; + // If we know that backTo targets the root route (full screen) we want to use it. + const fullScreenNavigator = stateForBackTo.routes.find((rt) => rt.name === NAVIGATORS.FULL_SCREEN_NAVIGATOR); + if (fullScreenNavigator && fullScreenNavigator.state) { + return fullScreenNavigator as NavigationPartialRoute; + } + + // If we know that backTo targets a central pane screen we want to use it. + const centralPaneScreen = stateForBackTo.routes.find((rt) => isCentralPaneName(rt.name)); + if (centralPaneScreen) { + return centralPaneScreen as NavigationPartialRoute; } } } @@ -191,7 +191,7 @@ function getAdaptedState(state: PartialState if (focusedRHPRoute) { let matchingRootRoute = getMatchingRootRouteForRHPRoute(focusedRHPRoute); const isRHPScreenOpenedFromLHN = focusedRHPRoute?.name && RHP_SCREENS_OPENED_FROM_LHN.includes(focusedRHPRoute?.name as RHPScreenOpenedFromLHN); - // This may happen if this RHP doens't have a route that should be under the overlay defined. + // This may happen if this RHP doesn't have a route that should be under the overlay defined. if (!matchingRootRoute || isRHPScreenOpenedFromLHN) { metainfo.isCentralPaneAndBottomTabMandatory = false; metainfo.isFullScreenNavigatorMandatory = false; diff --git a/src/libs/PolicyDistanceRatesUtils.ts b/src/libs/PolicyDistanceRatesUtils.ts index 0c5493f2f97b..8e4d68f78b4c 100644 --- a/src/libs/PolicyDistanceRatesUtils.ts +++ b/src/libs/PolicyDistanceRatesUtils.ts @@ -2,7 +2,6 @@ import type {FormInputErrors, FormOnyxValues} from '@components/Form/types'; import CONST from '@src/CONST'; import type ONYXKEYS from '@src/ONYXKEYS'; import type {Rate} from '@src/types/onyx/Policy'; -import * as CurrencyUtils from './CurrencyUtils'; import getPermittedDecimalSeparator from './getPermittedDecimalSeparator'; import * as Localize from './Localize'; import * as MoneyRequestUtils from './MoneyRequestUtils'; @@ -18,7 +17,7 @@ function validateRateValue(values: FormOnyxValues, currency: stri const decimalSeparator = toLocaleDigit('.'); // Allow one more decimal place for accuracy - const rateValueRegex = RegExp(String.raw`^-?\d{0,8}([${getPermittedDecimalSeparator(decimalSeparator)}]\d{0,${CurrencyUtils.getCurrencyDecimals(currency) + 1}})?$`, 'i'); + const rateValueRegex = RegExp(String.raw`^-?\d{0,8}([${getPermittedDecimalSeparator(decimalSeparator)}]\d{0,${CONST.MAX_TAX_RATE_DECIMAL_PLACES}})?$`, 'i'); if (!rateValueRegex.test(parsedRate) || parsedRate === '') { errors.rate = Localize.translateLocal('common.error.invalidRateError'); } else if (NumberUtils.parseFloatAnyLocale(parsedRate) <= 0) { diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts index c95f6b5a371a..e7479920b260 100644 --- a/src/libs/PolicyUtils.ts +++ b/src/libs/PolicyUtils.ts @@ -219,7 +219,7 @@ const isPolicyOwner = (policy: OnyxInputOrEntry, currentUserAccountID: n * * If includeMemberWithErrors is false, We only return members without errors. Otherwise, the members with errors would immediately be removed before the user has a chance to read the error. */ -function getMemberAccountIDsForWorkspace(employeeList: PolicyEmployeeList | undefined, includeMemberWithErrors = false): MemberEmailsToAccountIDs { +function getMemberAccountIDsForWorkspace(employeeList: PolicyEmployeeList | undefined, includeMemberWithErrors = false, includeMemberWithPendingDelete = true): MemberEmailsToAccountIDs { const members = employeeList ?? {}; const memberEmailsToAccountIDs: MemberEmailsToAccountIDs = {}; Object.keys(members).forEach((email) => { @@ -229,6 +229,12 @@ function getMemberAccountIDsForWorkspace(employeeList: PolicyEmployeeList | unde return; } } + if (!includeMemberWithPendingDelete) { + const member = members?.[email]; + if (member.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { + return; + } + } const personalDetail = getPersonalDetailByEmail(email); if (!personalDetail?.login) { return; diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts index 8d567509c90e..15d7728ba35a 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -1735,7 +1735,7 @@ function getRemovedFromApprovalChainMessage(reportAction: OnyxEntry displayName ?? login ?? 'Unknown Submitter', ); - return Localize.translateLocal('workspaceActions.removedFromApprovalWorkflow', {submittersNames}); + return Localize.translateLocal('workspaceActions.removedFromApprovalWorkflow', {submittersNames, count: submittersNames.length}); } function isCardIssuedAction(reportAction: OnyxEntry) { diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 684274bc0079..78ebdd92751e 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -351,11 +351,19 @@ type OptimisticTaskReportAction = Pick< | 'delegateAccountID' >; -type OptimisticWorkspaceChats = { +type AnnounceRoomOnyxData = { + onyxOptimisticData: OnyxUpdate[]; + onyxSuccessData: OnyxUpdate[]; + onyxFailureData: OnyxUpdate[]; +}; + +type OptimisticAnnounceChat = { announceChatReportID: string; - announceChatData: OptimisticChatReport; - announceReportActionData: Record; - announceCreatedReportActionID: string; + announceChatReportActionID: string; + announceChatData: AnnounceRoomOnyxData; +}; + +type OptimisticWorkspaceChats = { adminsChatReportID: string; adminsChatData: OptimisticChatReport; adminsReportActionData: Record; @@ -1997,7 +2005,7 @@ function getWorkspaceIcon(report: OnyxInputOrEntry, policy?: OnyxInputOr const iconFromCache = workSpaceIconsCache.get(cacheKey); // disabling to protect against empty strings // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - const policyAvatarURL = report?.policyAvatar || allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${report?.policyID}`]?.avatarURL; + const policyAvatarURL = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${report?.policyID}`]?.avatarURL || report?.policyAvatar; // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const policyExpenseChatAvatarSource = policyAvatarURL || getDefaultWorkspaceAvatar(workspaceName); @@ -5603,24 +5611,113 @@ function buildOptimisticDismissedViolationReportAction( }; } -function buildOptimisticWorkspaceChats(policyID: string, policyName: string, expenseReportId?: string): OptimisticWorkspaceChats { +function buildOptimisticAnnounceChat(policyID: string, accountIDs: number[]): OptimisticAnnounceChat { + const announceReport = getRoom(CONST.REPORT.CHAT_TYPE.POLICY_ANNOUNCE, policyID); + const policy = getPolicy(policyID); + const announceRoomOnyxData: AnnounceRoomOnyxData = { + onyxOptimisticData: [], + onyxSuccessData: [], + onyxFailureData: [], + }; + + // Do not create #announce room if the room already exists or if there are less than 3 participants in workspace + if (accountIDs.length < 3 || announceReport) { + return { + announceChatReportID: '', + announceChatReportActionID: '', + announceChatData: announceRoomOnyxData, + }; + } + const announceChatData = buildOptimisticChatReport( - currentUserAccountID ? [currentUserAccountID] : [], + accountIDs, CONST.REPORT.WORKSPACE_CHAT_ROOMS.ANNOUNCE, CONST.REPORT.CHAT_TYPE.POLICY_ANNOUNCE, policyID, CONST.POLICY.OWNER_ACCOUNT_ID_FAKE, false, - policyName, + policy?.name, undefined, CONST.REPORT.WRITE_CAPABILITIES.ADMINS, CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS, ); - const announceChatReportID = announceChatData.reportID; const announceCreatedAction = buildOptimisticCreatedReportAction(CONST.POLICY.OWNER_EMAIL_FAKE); - const announceReportActionData = { - [announceCreatedAction.reportActionID]: announceCreatedAction, + announceRoomOnyxData.onyxOptimisticData.push( + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT}${announceChatData.reportID}`, + value: { + pendingFields: { + addWorkspaceRoom: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + }, + ...announceChatData, + }, + }, + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT_DRAFT}${announceChatData.reportID}`, + value: null, + }, + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${announceChatData.reportID}`, + value: { + [announceCreatedAction.reportActionID]: announceCreatedAction, + }, + }, + ); + announceRoomOnyxData.onyxSuccessData.push( + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${announceChatData.reportID}`, + value: { + pendingFields: { + addWorkspaceRoom: null, + }, + pendingAction: null, + isOptimisticReport: false, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${announceChatData.reportID}`, + value: { + [announceCreatedAction.reportActionID]: { + pendingAction: null, + }, + }, + }, + ); + announceRoomOnyxData.onyxFailureData.push( + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${announceChatData.reportID}`, + value: { + pendingFields: { + addWorkspaceRoom: null, + }, + pendingAction: null, + isOptimisticReport: false, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${announceChatData.reportID}`, + value: { + [announceCreatedAction.reportActionID]: { + pendingAction: null, + }, + }, + }, + ); + return { + announceChatReportID: announceChatData.reportID, + announceChatReportActionID: announceCreatedAction.reportActionID, + announceChatData: announceRoomOnyxData, }; +} + +function buildOptimisticWorkspaceChats(policyID: string, policyName: string, expenseReportId?: string): OptimisticWorkspaceChats { const pendingChatMembers = getPendingChatMembers(currentUserAccountID ? [currentUserAccountID] : [], [], CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); const adminsChatData = { ...buildOptimisticChatReport( @@ -5665,10 +5762,6 @@ function buildOptimisticWorkspaceChats(policyID: string, policyName: string, exp }; return { - announceChatReportID, - announceChatData, - announceReportActionData, - announceCreatedReportActionID: announceCreatedAction.reportActionID, adminsChatReportID, adminsChatData, adminsReportActionData, @@ -7942,6 +8035,7 @@ export { buildOptimisticTaskReport, buildOptimisticTaskReportAction, buildOptimisticUnHoldReportAction, + buildOptimisticAnnounceChat, buildOptimisticWorkspaceChats, buildParticipantsFromAccountIDs, buildTransactionThread, diff --git a/src/libs/SearchUtils.ts b/src/libs/SearchUtils.ts index 0218c17cdac6..bd402b65d86d 100644 --- a/src/libs/SearchUtils.ts +++ b/src/libs/SearchUtils.ts @@ -311,21 +311,30 @@ function getListItem(type: SearchDataTypes, status: SearchStatus): ListItemType< if (type === CONST.SEARCH.DATA_TYPES.CHAT) { return ChatListItem; } - return status === CONST.SEARCH.STATUS.EXPENSE.ALL ? TransactionListItem : ReportListItem; + if (status === CONST.SEARCH.STATUS.EXPENSE.ALL) { + return TransactionListItem; + } + return ReportListItem; } function getSections(type: SearchDataTypes, status: SearchStatus, data: OnyxTypes.SearchResults['data'], metadata: OnyxTypes.SearchResults['search']) { if (type === CONST.SEARCH.DATA_TYPES.CHAT) { return getReportActionsSections(data); } - return status === CONST.SEARCH.STATUS.EXPENSE.ALL ? getTransactionsSections(data, metadata) : getReportSections(data, metadata); + if (status === CONST.SEARCH.STATUS.EXPENSE.ALL) { + return getTransactionsSections(data, metadata); + } + return getReportSections(data, metadata); } function getSortedSections(type: SearchDataTypes, status: SearchStatus, data: ListItemDataType, sortBy?: SearchColumnType, sortOrder?: SortOrder) { if (type === CONST.SEARCH.DATA_TYPES.CHAT) { return getSortedReportActionData(data as ReportActionListItemType[]); } - return status === CONST.SEARCH.STATUS.EXPENSE.ALL ? getSortedTransactionData(data as TransactionListItemType[], sortBy, sortOrder) : getSortedReportData(data as ReportListItemType[]); + if (status === CONST.SEARCH.STATUS.EXPENSE.ALL) { + return getSortedTransactionData(data as TransactionListItemType[], sortBy, sortOrder); + } + return getSortedReportData(data as ReportListItemType[]); } function getSortedTransactionData(data: TransactionListItemType[], sortBy?: SearchColumnType, sortOrder?: SortOrder) { diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts index bc553ea86d70..5262cc4dc4ff 100644 --- a/src/libs/actions/IOU.ts +++ b/src/libs/actions/IOU.ts @@ -3398,8 +3398,6 @@ function categorizeTrackedExpense( receipt, policyExpenseChatReportID: createdWorkspaceParams?.expenseChatReportID, policyExpenseCreatedReportActionID: createdWorkspaceParams?.expenseCreatedReportActionID, - announceChatReportID: createdWorkspaceParams?.announceChatReportID, - announceCreatedReportActionID: createdWorkspaceParams?.announceCreatedReportActionID, adminsChatReportID: createdWorkspaceParams?.adminsChatReportID, adminsCreatedReportActionID: createdWorkspaceParams?.adminsCreatedReportActionID, }; @@ -3475,8 +3473,6 @@ function shareTrackedExpense( receipt, policyExpenseChatReportID: createdWorkspaceParams?.expenseChatReportID, policyExpenseCreatedReportActionID: createdWorkspaceParams?.expenseCreatedReportActionID, - announceChatReportID: createdWorkspaceParams?.announceChatReportID, - announceCreatedReportActionID: createdWorkspaceParams?.announceCreatedReportActionID, adminsChatReportID: createdWorkspaceParams?.adminsChatReportID, adminsCreatedReportActionID: createdWorkspaceParams?.adminsCreatedReportActionID, }; @@ -6643,23 +6639,10 @@ function getPayMoneyRequestParams( successData: policySuccessData, params, } = Policy.buildPolicyData(currentUserEmail, true, undefined, payerPolicyID); - const { - announceChatReportID, - announceCreatedReportActionID, - adminsChatReportID, - adminsCreatedReportActionID, - expenseChatReportID, - expenseCreatedReportActionID, - customUnitRateID, - customUnitID, - ownerEmail, - policyName, - } = params; + const {adminsChatReportID, adminsCreatedReportActionID, expenseChatReportID, expenseCreatedReportActionID, customUnitRateID, customUnitID, ownerEmail, policyName} = params; policyParams = { policyID: payerPolicyID, - announceChatReportID, - announceCreatedReportActionID, adminsChatReportID, adminsCreatedReportActionID, expenseChatReportID, @@ -7581,8 +7564,6 @@ function payInvoice(paymentMethodType: PaymentMethodType, chatReport: OnyxTypes. params: { reportActionID, policyID, - announceChatReportID, - announceCreatedReportActionID, adminsChatReportID, adminsCreatedReportActionID, expenseChatReportID, @@ -7608,8 +7589,6 @@ function payInvoice(paymentMethodType: PaymentMethodType, chatReport: OnyxTypes. params = { ...params, policyID, - announceChatReportID, - announceCreatedReportActionID, adminsChatReportID, adminsCreatedReportActionID, expenseChatReportID, diff --git a/src/libs/actions/Modal.ts b/src/libs/actions/Modal.ts index 01ac832336ab..00853e9546d5 100644 --- a/src/libs/actions/Modal.ts +++ b/src/libs/actions/Modal.ts @@ -32,9 +32,11 @@ function closeTop() { } if (onModalClose) { closeModals[closeModals.length - 1](isNavigate); + closeModals.pop(); return; } closeModals[closeModals.length - 1](); + closeModals.pop(); } /** diff --git a/src/libs/actions/OnyxUpdates.ts b/src/libs/actions/OnyxUpdates.ts index 672f325be58a..1ba50d08e449 100644 --- a/src/libs/actions/OnyxUpdates.ts +++ b/src/libs/actions/OnyxUpdates.ts @@ -42,6 +42,14 @@ function applyHTTPSOnyxUpdates(request: Request, response: Response) { return updateHandler(request.successData); } if (response.jsonCode !== 200 && request.failureData) { + // 460 jsonCode in Expensify world means "admin required". + // Typically, this would only happen if a user attempts an API command that requires policy admin access when they aren't an admin. + // In this case, we don't want to apply failureData because it will likely result in a RedBrickRoad error on a policy field which is not accessible. + // Meaning that there's a red dot you can't dismiss. + if (response.jsonCode === 460) { + Log.info('[OnyxUpdateManager] Received 460 status code, not applying failure data'); + return Promise.resolve(); + } return updateHandler(request.failureData); } return Promise.resolve(); diff --git a/src/libs/actions/Policy/Member.ts b/src/libs/actions/Policy/Member.ts index 8c2a66a8ccf6..f4d2287aca4c 100644 --- a/src/libs/actions/Policy/Member.ts +++ b/src/libs/actions/Policy/Member.ts @@ -605,7 +605,7 @@ function clearWorkspaceOwnerChangeFlow(policyID: string) { * Adds members to the specified workspace/policyID * Please see https://github.com/Expensify/App/blob/main/README.md#Security for more details */ -function addMembersToWorkspace(invitedEmailsToAccountIDs: InvitedEmailsToAccountIDs, welcomeNote: string, policyID: string) { +function addMembersToWorkspace(invitedEmailsToAccountIDs: InvitedEmailsToAccountIDs, welcomeNote: string, policyID: string, policyMemberAccountIDs: number[]) { const policyKey = `${ONYXKEYS.COLLECTION.POLICY}${policyID}` as const; const logins = Object.keys(invitedEmailsToAccountIDs).map((memberLogin) => PhoneNumber.addSMSDomainIfPhoneNumber(memberLogin)); const accountIDs = Object.values(invitedEmailsToAccountIDs); @@ -614,6 +614,8 @@ function addMembersToWorkspace(invitedEmailsToAccountIDs: InvitedEmailsToAccount const newPersonalDetailsOnyxData = PersonalDetailsUtils.getPersonalDetailsOnyxDataForOptimisticUsers(newLogins, newAccountIDs); const announceRoomMembers = buildAnnounceRoomMembersOnyxData(policyID, accountIDs); + const optimisticAnnounceChat = ReportUtils.buildOptimisticAnnounceChat(policyID, [...policyMemberAccountIDs, ...accountIDs]); + const announceRoomChat = optimisticAnnounceChat.announceChatData; // create onyx data for policy expense chats for each new member const membersChats = createPolicyExpenseChats(policyID, invitedEmailsToAccountIDs); @@ -640,7 +642,7 @@ function addMembersToWorkspace(invitedEmailsToAccountIDs: InvitedEmailsToAccount }, }, ]; - optimisticData.push(...newPersonalDetailsOnyxData.optimisticData, ...membersChats.onyxOptimisticData, ...announceRoomMembers.onyxOptimisticData); + optimisticData.push(...newPersonalDetailsOnyxData.optimisticData, ...membersChats.onyxOptimisticData, ...announceRoomChat.onyxOptimisticData, ...announceRoomMembers.onyxOptimisticData); const successData: OnyxUpdate[] = [ { @@ -651,7 +653,7 @@ function addMembersToWorkspace(invitedEmailsToAccountIDs: InvitedEmailsToAccount }, }, ]; - successData.push(...newPersonalDetailsOnyxData.finallyData, ...membersChats.onyxSuccessData, ...announceRoomMembers.onyxSuccessData); + successData.push(...newPersonalDetailsOnyxData.finallyData, ...membersChats.onyxSuccessData, ...announceRoomChat.onyxSuccessData, ...announceRoomMembers.onyxSuccessData); const failureData: OnyxUpdate[] = [ { @@ -665,10 +667,12 @@ function addMembersToWorkspace(invitedEmailsToAccountIDs: InvitedEmailsToAccount }, }, ]; - failureData.push(...membersChats.onyxFailureData, ...announceRoomMembers.onyxFailureData); + failureData.push(...membersChats.onyxFailureData, ...announceRoomChat.onyxFailureData, ...announceRoomMembers.onyxFailureData); const params: AddMembersToWorkspaceParams = { employees: JSON.stringify(logins.map((login) => ({email: login}))), + ...(optimisticAnnounceChat.announceChatReportID ? {announceChatReportID: optimisticAnnounceChat.announceChatReportID} : {}), + ...(optimisticAnnounceChat.announceChatReportActionID ? {announceCreatedReportActionID: optimisticAnnounceChat.announceChatReportActionID} : {}), welcomeNote: Parser.replace(welcomeNote), policyID, }; diff --git a/src/libs/actions/Policy/Policy.ts b/src/libs/actions/Policy/Policy.ts index 8f8bba8e916f..60cab1787700 100644 --- a/src/libs/actions/Policy/Policy.ts +++ b/src/libs/actions/Policy/Policy.ts @@ -1532,10 +1532,6 @@ function buildPolicyData(policyOwnerEmail = '', makeMeAdmin = false, policyName const {customUnits, customUnitID, customUnitRateID, outputCurrency} = buildOptimisticCustomUnits(); const { - announceChatReportID, - announceChatData, - announceReportActionData, - announceCreatedReportActionID, adminsChatReportID, adminsChatData, adminsReportActionData, @@ -1593,26 +1589,6 @@ function buildPolicyData(policyOwnerEmail = '', makeMeAdmin = false, policyName }, }, }, - { - onyxMethod: Onyx.METHOD.SET, - key: `${ONYXKEYS.COLLECTION.REPORT}${announceChatReportID}`, - value: { - pendingFields: { - addWorkspaceRoom: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, - }, - ...announceChatData, - }, - }, - { - onyxMethod: Onyx.METHOD.SET, - key: `${ONYXKEYS.COLLECTION.REPORT_DRAFT}${announceChatReportID}`, - value: null, - }, - { - onyxMethod: Onyx.METHOD.SET, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${announceChatReportID}`, - value: announceReportActionData, - }, { onyxMethod: Onyx.METHOD.SET, key: `${ONYXKEYS.COLLECTION.REPORT}${adminsChatReportID}`, @@ -1672,26 +1648,6 @@ function buildPolicyData(policyOwnerEmail = '', makeMeAdmin = false, policyName }, }, }, - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${announceChatReportID}`, - value: { - pendingFields: { - addWorkspaceRoom: null, - }, - pendingAction: null, - isOptimisticReport: false, - }, - }, - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${announceChatReportID}`, - value: { - [announceCreatedReportActionID]: { - pendingAction: null, - }, - }, - }, { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT}${adminsChatReportID}`, @@ -1741,16 +1697,6 @@ function buildPolicyData(policyOwnerEmail = '', makeMeAdmin = false, policyName key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, value: {employeeList: null}, }, - { - onyxMethod: Onyx.METHOD.SET, - key: `${ONYXKEYS.COLLECTION.REPORT}${announceChatReportID}`, - value: null, - }, - { - onyxMethod: Onyx.METHOD.SET, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${announceChatReportID}`, - value: null, - }, { onyxMethod: Onyx.METHOD.SET, key: `${ONYXKEYS.COLLECTION.REPORT}${adminsChatReportID}`, @@ -1787,14 +1733,12 @@ function buildPolicyData(policyOwnerEmail = '', makeMeAdmin = false, policyName const params: CreateWorkspaceParams = { policyID, - announceChatReportID, adminsChatReportID, expenseChatReportID, ownerEmail: policyOwnerEmail, makeMeAdmin, policyName: workspaceName, type: CONST.POLICY.TYPE.TEAM, - announceCreatedReportActionID, adminsCreatedReportActionID, expenseCreatedReportActionID, customUnitID, @@ -1832,8 +1776,10 @@ function createDraftWorkspace(policyOwnerEmail = '', makeMeAdmin = false, policy const {customUnits, customUnitID, customUnitRateID, outputCurrency} = buildOptimisticCustomUnits(); - const {expenseChatData, announceChatReportID, announceCreatedReportActionID, adminsChatReportID, adminsCreatedReportActionID, expenseChatReportID, expenseCreatedReportActionID} = - ReportUtils.buildOptimisticWorkspaceChats(policyID, workspaceName); + const {expenseChatData, adminsChatReportID, adminsCreatedReportActionID, expenseChatReportID, expenseCreatedReportActionID} = ReportUtils.buildOptimisticWorkspaceChats( + policyID, + workspaceName, + ); const optimisticData: OnyxUpdate[] = [ { @@ -1897,14 +1843,12 @@ function createDraftWorkspace(policyOwnerEmail = '', makeMeAdmin = false, policy const params: CreateWorkspaceParams = { policyID, - announceChatReportID, adminsChatReportID, expenseChatReportID, ownerEmail: policyOwnerEmail, makeMeAdmin, policyName: workspaceName, type: CONST.POLICY.TYPE.TEAM, - announceCreatedReportActionID, adminsCreatedReportActionID, expenseCreatedReportActionID, customUnitID, @@ -2168,10 +2112,6 @@ function createWorkspaceFromIOUPayment(iouReport: OnyxEntry): WorkspaceF const iouReportID = iouReport.reportID; const { - announceChatReportID, - announceChatData, - announceReportActionData, - announceCreatedReportActionID, adminsChatReportID, adminsChatData, adminsReportActionData, @@ -2238,21 +2178,6 @@ function createWorkspaceFromIOUPayment(iouReport: OnyxEntry): WorkspaceF key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, value: newWorkspace, }, - { - onyxMethod: Onyx.METHOD.SET, - key: `${ONYXKEYS.COLLECTION.REPORT}${announceChatReportID}`, - value: { - pendingFields: { - addWorkspaceRoom: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, - }, - ...announceChatData, - }, - }, - { - onyxMethod: Onyx.METHOD.SET, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${announceChatReportID}`, - value: announceReportActionData, - }, { onyxMethod: Onyx.METHOD.SET, key: `${ONYXKEYS.COLLECTION.REPORT}${adminsChatReportID}`, @@ -2309,25 +2234,6 @@ function createWorkspaceFromIOUPayment(iouReport: OnyxEntry): WorkspaceF }, }, }, - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${announceChatReportID}`, - value: { - pendingFields: { - addWorkspaceRoom: null, - }, - pendingAction: null, - }, - }, - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${announceChatReportID}`, - value: { - [Object.keys(announceChatData)[0]]: { - pendingAction: null, - }, - }, - }, { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT}${adminsChatReportID}`, @@ -2370,23 +2276,6 @@ function createWorkspaceFromIOUPayment(iouReport: OnyxEntry): WorkspaceF successData.push(...employeeWorkspaceChat.onyxSuccessData); const failureData: OnyxUpdate[] = [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${announceChatReportID}`, - value: { - pendingFields: { - addWorkspaceRoom: null, - }, - pendingAction: null, - }, - }, - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${announceChatReportID}`, - value: { - pendingAction: null, - }, - }, { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT}${adminsChatReportID}`, @@ -2589,14 +2478,12 @@ function createWorkspaceFromIOUPayment(iouReport: OnyxEntry): WorkspaceF const params: CreateWorkspaceFromIOUPaymentParams = { policyID, - announceChatReportID, adminsChatReportID, expenseChatReportID: workspaceChatReportID, ownerEmail: '', makeMeAdmin: false, policyName: workspaceName, type: CONST.POLICY.TYPE.TEAM, - announceCreatedReportActionID, adminsCreatedReportActionID, expenseCreatedReportActionID: workspaceChatCreatedReportActionID, customUnitID, diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index e53cac804b90..0fe2bfbf8d47 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -2689,28 +2689,31 @@ function openReportFromDeepLink(url: string) { return; } - // We need skip deeplinking if the user hasn't completed the guided setup flow. - Welcome.isOnboardingFlowCompleted({ - onNotCompleted: () => OnboardingFlow.startOnboardingFlow(), - onCompleted: () => { - const state = navigationRef.getRootState(); - const currentFocusedRoute = findFocusedRoute(state); + const handleDeeplinkNavigation = () => { + const state = navigationRef.getRootState(); + const currentFocusedRoute = findFocusedRoute(state); - if (isOnboardingFlowName(currentFocusedRoute?.name)) { - Welcome.setOnboardingErrorMessage(Localize.translateLocal('onboarding.purpose.errorBackButton')); - return; - } + if (isOnboardingFlowName(currentFocusedRoute?.name)) { + Welcome.setOnboardingErrorMessage(Localize.translateLocal('onboarding.purpose.errorBackButton')); + return; + } - if (shouldSkipDeepLinkNavigation(route)) { - return; - } + if (shouldSkipDeepLinkNavigation(route)) { + return; + } - if (isAuthenticated) { - return; - } + if (isAuthenticated) { + return; + } - Navigation.navigate(route as Route, CONST.NAVIGATION.ACTION_TYPE.PUSH); - }, + Navigation.navigate(route as Route, CONST.NAVIGATION.ACTION_TYPE.PUSH); + }; + + // We need skip deeplinking if the user hasn't completed the guided setup flow. + Welcome.isOnboardingFlowCompleted({ + onNotCompleted: OnboardingFlow.startOnboardingFlow, + onCompleted: handleDeeplinkNavigation, + onCanceled: handleDeeplinkNavigation, }); }); }, diff --git a/src/libs/actions/Search.ts b/src/libs/actions/Search.ts index 722e88808033..0f89232dc3cf 100644 --- a/src/libs/actions/Search.ts +++ b/src/libs/actions/Search.ts @@ -55,11 +55,78 @@ function saveSearch({queryJSON, newName}: {queryJSON: SearchQueryJSON; newName?: const saveSearchName = newName ?? queryJSON?.inputQuery ?? ''; const jsonQuery = JSON.stringify(queryJSON); - API.write(WRITE_COMMANDS.SAVE_SEARCH, {jsonQuery, newName: saveSearchName}); + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.SAVED_SEARCHES}`, + value: { + [queryJSON.hash]: { + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + name: saveSearchName, + query: queryJSON.inputQuery, + }, + }, + }, + ]; + + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.SAVED_SEARCHES}`, + value: { + [queryJSON.hash]: null, + }, + }, + ]; + + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.SAVED_SEARCHES}`, + value: { + [queryJSON.hash]: { + pendingAction: null, + }, + }, + }, + ]; + API.write(WRITE_COMMANDS.SAVE_SEARCH, {jsonQuery, newName: saveSearchName}, {optimisticData, failureData, successData}); } function deleteSavedSearch(hash: number) { - API.write(WRITE_COMMANDS.DELETE_SAVED_SEARCH, {hash}); + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.SAVED_SEARCHES}`, + value: { + [hash]: { + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, + }, + }, + }, + ]; + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.SAVED_SEARCHES}`, + value: { + [hash]: null, + }, + }, + ]; + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.SAVED_SEARCHES}`, + value: { + [hash]: { + pendingAction: null, + }, + }, + }, + ]; + + API.write(WRITE_COMMANDS.DELETE_SAVED_SEARCH, {hash}, {optimisticData, failureData, successData}); } function search({queryJSON, offset}: {queryJSON: SearchQueryJSON; offset?: number}) { diff --git a/src/libs/actions/Welcome/index.ts b/src/libs/actions/Welcome/index.ts index 75529a879104..fc921b16f4cf 100644 --- a/src/libs/actions/Welcome/index.ts +++ b/src/libs/actions/Welcome/index.ts @@ -23,6 +23,7 @@ let onboarding: OnboardingData; type HasCompletedOnboardingFlowProps = { onCompleted?: () => void; onNotCompleted?: () => void; + onCanceled?: () => void; }; type HasOpenedForTheFirstTimeFromHybridAppProps = { @@ -50,9 +51,10 @@ function onServerDataReady(): Promise { } let isOnboardingInProgress = false; -function isOnboardingFlowCompleted({onCompleted, onNotCompleted}: HasCompletedOnboardingFlowProps) { +function isOnboardingFlowCompleted({onCompleted, onNotCompleted, onCanceled}: HasCompletedOnboardingFlowProps) { isOnboardingFlowStatusKnownPromise.then(() => { if (Array.isArray(onboarding) || onboarding?.hasCompletedGuidedSetupFlow === undefined) { + onCanceled?.(); return; } diff --git a/src/libs/willBlurTextInputOnTapOutside/index.ts b/src/libs/willBlurTextInputOnTapOutside/index.ts index 987d8a1dfeea..31a40932189e 100644 --- a/src/libs/willBlurTextInputOnTapOutside/index.ts +++ b/src/libs/willBlurTextInputOnTapOutside/index.ts @@ -1,5 +1,6 @@ +import getIsNarrowLayout from '@libs/getIsNarrowLayout'; import type WillBlurTextInputOnTapOutside from './types'; -const willBlurTextInputOnTapOutside: WillBlurTextInputOnTapOutside = () => true; +const willBlurTextInputOnTapOutside: WillBlurTextInputOnTapOutside = () => !getIsNarrowLayout(); export default willBlurTextInputOnTapOutside; diff --git a/src/pages/ReportDetailsPage.tsx b/src/pages/ReportDetailsPage.tsx index e382596bebac..195c14698f7d 100644 --- a/src/pages/ReportDetailsPage.tsx +++ b/src/pages/ReportDetailsPage.tsx @@ -821,7 +821,7 @@ function ReportDetailsPage({policies, report, route}: ReportDetailsPageProps) { shouldEnableNewFocusManagement /> setIsDeleteModalVisible(false)} @@ -844,7 +844,7 @@ function ReportDetailsPage({policies, report, route}: ReportDetailsPageProps) { ReportUtils.navigateBackAfterDeleteTransaction(navigateBackToAfterDelete.current, true); } }} - prompt={caseID === CASES.DEFAULT ? translate('task.deleteConfirmation') : translate('iou.deleteConfirmation')} + prompt={caseID === CASES.DEFAULT ? translate('task.deleteConfirmation') : translate('iou.deleteConfirmation', {count: 1})} confirmText={translate('common.delete')} cancelText={translate('common.cancel')} danger diff --git a/src/pages/ReportParticipantsPage.tsx b/src/pages/ReportParticipantsPage.tsx index 579e6f7ec104..8835d7c8d5cc 100755 --- a/src/pages/ReportParticipantsPage.tsx +++ b/src/pages/ReportParticipantsPage.tsx @@ -309,7 +309,7 @@ function ReportParticipantsPage({report, route}: ReportParticipantsPageProps) { shouldAlwaysShowDropdownMenu pressOnEnter - customText={translate('workspace.common.selected', {selectedNumber: selectedMembers.length})} + customText={translate('workspace.common.selected', {count: selectedMembers.length})} buttonSize={CONST.DROPDOWN_BUTTON_SIZE.MEDIUM} onPress={() => null} isSplitButton={false} diff --git a/src/pages/RoomMembersPage.tsx b/src/pages/RoomMembersPage.tsx index 765c738bc574..f8c07e5b0a67 100644 --- a/src/pages/RoomMembersPage.tsx +++ b/src/pages/RoomMembersPage.tsx @@ -289,7 +289,7 @@ function RoomMembersPage({report, session, policies}: RoomMembersPageProps) { shouldAlwaysShowDropdownMenu pressOnEnter - customText={translate('workspace.common.selected', {selectedNumber: selectedMembers.length})} + customText={translate('workspace.common.selected', {count: selectedMembers.length})} buttonSize={CONST.DROPDOWN_BUTTON_SIZE.MEDIUM} onPress={() => null} options={bulkActionsButtonOptions} diff --git a/src/pages/Search/SavedSearchItemThreeDotMenu.tsx b/src/pages/Search/SavedSearchItemThreeDotMenu.tsx index fdb06828901e..bd7a94bc1840 100644 --- a/src/pages/Search/SavedSearchItemThreeDotMenu.tsx +++ b/src/pages/Search/SavedSearchItemThreeDotMenu.tsx @@ -2,18 +2,23 @@ import React, {useRef, useState} from 'react'; import {View} from 'react-native'; import type {PopoverMenuItem} from '@components/PopoverMenu'; import ThreeDotsMenu from '@components/ThreeDotsMenu'; +import useThemeStyles from '@hooks/useThemeStyles'; import CONST from '@src/CONST'; type SavedSearchItemThreeDotMenuProps = { menuItems: PopoverMenuItem[]; + isDisabledItem: boolean; }; -function SavedSearchItemThreeDotMenu({menuItems}: SavedSearchItemThreeDotMenuProps) { +function SavedSearchItemThreeDotMenu({menuItems, isDisabledItem}: SavedSearchItemThreeDotMenuProps) { const threeDotsMenuContainerRef = useRef(null); const [threeDotsMenuPosition, setThreeDotsMenuPosition] = useState({horizontal: 0, vertical: 0}); - + const styles = useThemeStyles(); return ( - + { diff --git a/src/pages/Search/SavedSearchRenamePage.tsx b/src/pages/Search/SavedSearchRenamePage.tsx index 9e4d6122ea4d..d2643591ebbf 100644 --- a/src/pages/Search/SavedSearchRenamePage.tsx +++ b/src/pages/Search/SavedSearchRenamePage.tsx @@ -57,6 +57,7 @@ function SavedSearchRenamePage({route}: {route: {params: {q: string; name: strin submitButtonText={translate('common.save')} onSubmit={onSaveSearch} style={[styles.mh5, styles.flex1]} + enabledWhenOffline > , + rightComponent: ( + + ), styles: [styles.alignItemsCenter], + pendingAction: item.pendingAction, + disabled: item.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, }; if (!isNarrow) { @@ -179,7 +185,7 @@ function SearchTypeMenu({queryJSON, searchName}: SearchTypeMenuProps) { if (!savedSearches) { return []; } - return Object.entries(savedSearches).map(([key, item], index) => createSavedSearchMenuItem(item as SaveSearchItem, key, shouldUseNarrowLayout, index)); + return Object.entries(savedSearches).map(([key, item], index) => createSavedSearchMenuItem(item, key, shouldUseNarrowLayout, index)); }; const renderSavedSearchesSection = useCallback( diff --git a/src/pages/Search/SearchTypeMenuNarrow.tsx b/src/pages/Search/SearchTypeMenuNarrow.tsx index 198f40ca9e44..c05d8f69a404 100644 --- a/src/pages/Search/SearchTypeMenuNarrow.tsx +++ b/src/pages/Search/SearchTypeMenuNarrow.tsx @@ -3,7 +3,7 @@ import {Animated, View} from 'react-native'; import type {TextStyle, ViewStyle} from 'react-native'; import Button from '@components/Button'; import Icon from '@components/Icon'; -import type {MenuItemBaseProps} from '@components/MenuItem'; +import type {MenuItemWithLink} from '@components/MenuItemList'; import PopoverMenu from '@components/PopoverMenu'; import type {PopoverMenuItem} from '@components/PopoverMenu'; import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; @@ -26,7 +26,7 @@ import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; import type {SearchTypeMenuItem} from './SearchTypeMenu'; -type SavedSearchMenuItem = MenuItemBaseProps & { +type SavedSearchMenuItem = MenuItemWithLink & { key: string; hash: string; query: string; @@ -120,11 +120,13 @@ function SearchTypeMenuNarrow({typeMenuItems, activeItemIndex, queryJSON, title, horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.RIGHT, vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.TOP, }} + disabled={item.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE} /> ), isSelected: currentSavedSearch?.hash === item.hash, + pendingAction: item.pendingAction, + disabled: item.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, })); - const allMenuItems = []; allMenuItems.push(...popoverMenuItems); @@ -136,7 +138,6 @@ function SearchTypeMenuNarrow({typeMenuItems, activeItemIndex, queryJSON, title, }); allMenuItems.push(...savedSearchItems); } - return ( (null); + const qrCodeRef = useRef(null); + const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const isReport = !!report?.reportID; @@ -83,6 +85,11 @@ function ShareCodePage({report, policy, backTo}: ShareCodePageProps) { }, [report, currentUserPersonalDetails, isReport]); const title = isReport ? ReportUtils.getReportName(report) : currentUserPersonalDetails.displayName ?? ''; + // We should remove this logic once https://github.com/Expensify/App/issues/19834 is done + // We shouldn't introduce platform specific code in our codebase + // This is a temporary solution while Web is not supported for the QR code download feature + const platform = getPlatform(); + const isNative = platform === CONST.PLATFORM.IOS || platform === CONST.PLATFORM.ANDROID; const urlWithTrailingSlash = Url.addTrailingForwardSlash(environmentURL); const url = isReport ? `${urlWithTrailingSlash}${ROUTES.REPORT_WITH_ID.getRoute(report.reportID)}` @@ -112,24 +119,17 @@ function ShareCodePage({report, policy, backTo}: ShareCodePageProps) { /> - {/* - Right now QR code download button is not shown anymore - This is a temporary measure because right now it's broken because of the Fabric update. - We need to wait for react-native v0.74 to be released so react-native-view-shot gets fixed. - - Please see https://github.com/Expensify/App/issues/40110 to see if it can be re-enabled. - */} - @@ -143,6 +143,18 @@ function ShareCodePage({report, policy, backTo}: ShareCodePageProps) { onPress={() => Clipboard.setString(url)} shouldLimitWidth={false} /> + {/* Remove this platform specific condition once https://github.com/Expensify/App/issues/19834 is done. + We shouldn't introduce platform specific code in our codebase. + This is a temporary solution while Web is not supported for the QR code download feature */} + {isNative && ( + qrCodeRef.current?.download?.()} + /> + )} ; - transaction: OnyxEntry; - reportMetadata: OnyxEntry; -}; +type TransactionReceiptProps = StackScreenProps; -type TransactionReceiptProps = TransactionReceiptOnyxProps & StackScreenProps; - -function TransactionReceipt({transaction, report, reportMetadata = {isLoadingInitialReportActions: true}, route}: TransactionReceiptProps) { +function TransactionReceipt({route}: TransactionReceiptProps) { + const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${route.params.reportID ?? '-1'}`); + const [transaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${route.params.transactionID ?? '-1'}`); + const [reportMetadata = {isLoadingInitialReportActions: true}] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${route.params.reportID ?? '-1'}`); const receiptURIs = ReceiptUtils.getThumbnailAndImageURIs(transaction); const imageSource = tryResolveUrlFromApiRoot(receiptURIs.image ?? ''); @@ -65,7 +59,7 @@ function TransactionReceipt({transaction, report, reportMetadata = {isLoadingIni originalFileName={receiptURIs?.filename} defaultOpen onModalClose={() => { - Navigation.goBack(ROUTES.REPORT_WITH_ID.getRoute(report?.reportID ?? '-1')); + Navigation.dismissModal(report?.reportID ?? '-1'); }} isLoading={!transaction && reportMetadata?.isLoadingInitialReportActions} shouldShowNotFoundPage={shouldShowNotFoundPage} @@ -75,14 +69,4 @@ function TransactionReceipt({transaction, report, reportMetadata = {isLoadingIni TransactionReceipt.displayName = 'TransactionReceipt'; -export default withOnyx({ - report: { - key: ({route}) => `${ONYXKEYS.COLLECTION.REPORT}${route.params.reportID ?? '-1'}`, - }, - transaction: { - key: ({route}) => `${ONYXKEYS.COLLECTION.TRANSACTION}${route.params.transactionID ?? '-1'}`, - }, - reportMetadata: { - key: ({route}) => `${ONYXKEYS.COLLECTION.REPORT_METADATA}${route.params.reportID ?? '-1'}`, - }, -})(TransactionReceipt); +export default TransactionReceipt; diff --git a/src/pages/home/HeaderView.tsx b/src/pages/home/HeaderView.tsx index 180f7da970aa..d463fa1cec38 100644 --- a/src/pages/home/HeaderView.tsx +++ b/src/pages/home/HeaderView.tsx @@ -78,7 +78,7 @@ function HeaderView({report, parentReportAction, reportID, onNavigationMenuButto const isTaskReport = ReportUtils.isTaskReport(report); const reportHeaderData = !isTaskReport && !isChatThread && report?.parentReportID ? parentReport : report; // Use sorted display names for the title for group chats on native small screen widths - const title = ReportUtils.getReportName(reportHeaderData, undefined, parentReportAction, personalDetails, invoiceReceiverPolicy); + const title = ReportUtils.getReportName(reportHeaderData, policy, parentReportAction, personalDetails, invoiceReceiverPolicy); const subtitle = ReportUtils.getChatRoomSubtitle(reportHeaderData); const parentNavigationSubtitleData = ReportUtils.getParentNavigationSubtitle(reportHeaderData); const reportDescription = ReportUtils.getReportDescriptionText(report); @@ -124,7 +124,7 @@ function HeaderView({report, parentReportAction, reportID, onNavigationMenuButto const shouldShowSubscript = ReportUtils.shouldReportShowSubscript(report); const defaultSubscriptSize = ReportUtils.isExpenseRequest(report) ? CONST.AVATAR_SIZE.SMALL_NORMAL : CONST.AVATAR_SIZE.DEFAULT; - const icons = ReportUtils.getIcons(reportHeaderData, personalDetails, null, '', -1, undefined, invoiceReceiverPolicy); + const icons = ReportUtils.getIcons(reportHeaderData, personalDetails, null, '', -1, policy, invoiceReceiverPolicy); const brickRoadIndicator = ReportUtils.hasReportNameError(report) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : ''; const shouldShowBorderBottom = !isTaskReport || !shouldUseNarrowLayout; const shouldDisableDetailPage = ReportUtils.shouldDisableDetailPage(report); diff --git a/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx b/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx index 28d985537ff5..4fc7cc137068 100644 --- a/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx +++ b/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx @@ -67,10 +67,10 @@ type AttachmentPickerWithMenuItemsProps = { onTriggerAttachmentPicker: () => void; /** Called when cancelling the attachment picker */ - onCanceledAttachmentPicker: () => void; + onCanceledAttachmentPicker?: () => void; /** Called when the menu with the items is closed after it was open */ - onMenuClosed: () => void; + onMenuClosed?: () => void; /** Called when the add action button is pressed */ onAddActionPressed: () => void; @@ -192,7 +192,7 @@ function AttachmentPickerWithMenuItems({ const onPopoverMenuClose = () => { setMenuVisibility(false); - onMenuClosed(); + onMenuClosed?.(); }; const prevIsFocused = usePrevious(isFocused); diff --git a/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx b/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx index 69bceec2bd9f..e4b0eef6ca52 100644 --- a/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx +++ b/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx @@ -207,13 +207,6 @@ function ReportActionCompose({ const isKeyboardVisibleWhenShowingModalRef = useRef(false); const isNextModalWillOpenRef = useRef(false); - const restoreKeyboardState = useCallback(() => { - if (!isKeyboardVisibleWhenShowingModalRef.current || isNextModalWillOpenRef.current) { - return; - } - focus(); - isKeyboardVisibleWhenShowingModalRef.current = false; - }, []); const containerRef = useRef(null); const measureContainer = useCallback( @@ -263,8 +256,7 @@ function ReportActionCompose({ const onAttachmentPreviewClose = useCallback(() => { updateShouldShowSuggestionMenuToFalse(); setIsAttachmentPreviewActive(false); - restoreKeyboardState(); - }, [updateShouldShowSuggestionMenuToFalse, restoreKeyboardState]); + }, [updateShouldShowSuggestionMenuToFalse]); /** * Add a new comment to this chat @@ -455,11 +447,6 @@ function ReportActionCompose({ isMenuVisible={isMenuVisible} onTriggerAttachmentPicker={onTriggerAttachmentPicker} raiseIsScrollLikelyLayoutTriggered={raiseIsScrollLikelyLayoutTriggered} - onCanceledAttachmentPicker={() => { - isNextModalWillOpenRef.current = false; - restoreKeyboardState(); - }} - onMenuClosed={restoreKeyboardState} onAddActionPressed={onAddActionPressed} onItemSelected={onItemSelected} actionButtonRef={actionButtonRef} diff --git a/src/pages/home/report/ReportActionItemCreated.tsx b/src/pages/home/report/ReportActionItemCreated.tsx index 4d4a560953ed..700c87ee3529 100644 --- a/src/pages/home/report/ReportActionItemCreated.tsx +++ b/src/pages/home/report/ReportActionItemCreated.tsx @@ -1,13 +1,12 @@ -import lodashIsEqual from 'lodash/isEqual'; import React, {memo} from 'react'; import {View} from 'react-native'; -import type {OnyxEntry} from 'react-native-onyx'; -import {useOnyx, withOnyx} from 'react-native-onyx'; +import {useOnyx} from 'react-native-onyx'; import MultipleAvatars from '@components/MultipleAvatars'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback'; import ReportWelcomeText from '@components/ReportWelcomeText'; import useLocalize from '@hooks/useLocalize'; +import usePolicy from '@hooks/usePolicy'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; import Navigation from '@libs/Navigation/Navigation'; @@ -15,21 +14,9 @@ import * as ReportUtils from '@libs/ReportUtils'; import {navigateToConciergeChatAndDeleteReport} from '@userActions/Report'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {PersonalDetailsList, Policy, Report} from '@src/types/onyx'; import AnimatedEmptyStateBackground from './AnimatedEmptyStateBackground'; -type ReportActionItemCreatedOnyxProps = { - /** The report currently being looked at */ - report: OnyxEntry; - - /** The policy object for the current route */ - policy: OnyxEntry; - - /** Personal details of all the users */ - personalDetails: OnyxEntry; -}; - -type ReportActionItemCreatedProps = ReportActionItemCreatedOnyxProps & { +type ReportActionItemCreatedProps = { /** The id of the report */ reportID: string; @@ -37,7 +24,10 @@ type ReportActionItemCreatedProps = ReportActionItemCreatedOnyxProps & { // eslint-disable-next-line react/no-unused-prop-types policyID: string | undefined; }; -function ReportActionItemCreated({report, personalDetails, policy, reportID}: ReportActionItemCreatedProps) { +function ReportActionItemCreated({policyID, reportID}: ReportActionItemCreatedProps) { + const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); + const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST); + const policy = usePolicy(policyID); const styles = useThemeStyles(); const {translate} = useLocalize(); @@ -48,7 +38,7 @@ function ReportActionItemCreated({report, personalDetails, policy, reportID}: Re return null; } - let icons = ReportUtils.getIcons(report, personalDetails, null, '', -1, undefined, invoiceReceiverPolicy); + let icons = ReportUtils.getIcons(report, personalDetails, null, '', -1, policy, invoiceReceiverPolicy); const shouldDisableDetailPage = ReportUtils.shouldDisableDetailPage(report); if (ReportUtils.isInvoiceRoom(report) && ReportUtils.isCurrentUserInvoiceReceiver(report)) { @@ -99,33 +89,4 @@ function ReportActionItemCreated({report, personalDetails, policy, reportID}: Re ReportActionItemCreated.displayName = 'ReportActionItemCreated'; -export default withOnyx({ - report: { - key: ({reportID}) => `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, - }, - - policy: { - key: ({policyID}) => `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, - }, - - personalDetails: { - key: ONYXKEYS.PERSONAL_DETAILS_LIST, - }, -})( - memo( - ReportActionItemCreated, - (prevProps, nextProps) => - prevProps.policy?.name === nextProps.policy?.name && - prevProps.policy?.avatarURL === nextProps.policy?.avatarURL && - prevProps.report?.stateNum === nextProps.report?.stateNum && - prevProps.report?.statusNum === nextProps.report?.statusNum && - prevProps.report?.lastReadTime === nextProps.report?.lastReadTime && - prevProps.report?.description === nextProps.report?.description && - prevProps.personalDetails === nextProps.personalDetails && - prevProps.policy?.description === nextProps.policy?.description && - prevProps.report?.reportName === nextProps.report?.reportName && - prevProps.report?.avatarUrl === nextProps.report?.avatarUrl && - lodashIsEqual(prevProps.report?.invoiceReceiver, nextProps.report?.invoiceReceiver) && - prevProps.report?.errorFields === nextProps.report?.errorFields, - ), -); +export default memo(ReportActionItemCreated); diff --git a/src/pages/home/report/ReportActionItemSingle.tsx b/src/pages/home/report/ReportActionItemSingle.tsx index 56eaf814ff10..91f12339ee07 100644 --- a/src/pages/home/report/ReportActionItemSingle.tsx +++ b/src/pages/home/report/ReportActionItemSingle.tsx @@ -14,6 +14,7 @@ import Text from '@components/Text'; import Tooltip from '@components/Tooltip'; import UserDetailsTooltip from '@components/UserDetailsTooltip'; import useLocalize from '@hooks/useLocalize'; +import usePolicy from '@hooks/usePolicy'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -82,6 +83,7 @@ function ReportActionItemSingle({ const StyleUtils = useStyleUtils(); const {translate} = useLocalize(); const personalDetails = usePersonalDetails() ?? CONST.EMPTY_OBJECT; + const policy = usePolicy(report?.policyID); const delegatePersonalDetails = personalDetails[action?.delegateAccountID ?? '']; const actorAccountID = ReportUtils.getReportActionActorAccountID(action); const [invoiceReceiverPolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${report?.invoiceReceiver && 'policyID' in report.invoiceReceiver ? report.invoiceReceiver.policyID : -1}`); @@ -102,9 +104,9 @@ function ReportActionItemSingle({ let avatarId: number | string | undefined = actorAccountID; if (isWorkspaceActor) { - displayName = ReportUtils.getPolicyName(report); + displayName = ReportUtils.getPolicyName(report, undefined, policy); actorHint = displayName; - avatarSource = ReportUtils.getWorkspaceIcon(report).source; + avatarSource = ReportUtils.getWorkspaceIcon(report, policy).source; avatarId = report?.policyID; } else if (action?.delegateAccountID && personalDetails[action?.delegateAccountID]) { displayName = delegatePersonalDetails?.displayName ?? ''; diff --git a/src/pages/home/report/ReportAttachments.tsx b/src/pages/home/report/ReportAttachments.tsx index 369d5cef6ee4..1e16cfdddf4f 100644 --- a/src/pages/home/report/ReportAttachments.tsx +++ b/src/pages/home/report/ReportAttachments.tsx @@ -1,5 +1,5 @@ import type {StackScreenProps} from '@react-navigation/stack'; -import React, {useCallback, useEffect, useRef} from 'react'; +import React, {useCallback} from 'react'; import {useOnyx} from 'react-native-onyx'; import AttachmentModal from '@components/AttachmentModal'; import type {Attachment} from '@components/Attachments/types'; @@ -19,14 +19,6 @@ function ReportAttachments({route}: ReportAttachmentsProps) { const accountID = route.params.accountID; const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID || -1}`); const [isLoadingApp] = useOnyx(ONYXKEYS.IS_LOADING_APP); - const hasDismissedModalRef = useRef(false); - - useEffect( - () => () => { - hasDismissedModalRef.current = false; - }, - [], - ); // In native the imported images sources are of type number. Ref: https://reactnative.dev/docs/image#imagesource const source = Number(route.params.source) || route.params.source; @@ -48,10 +40,7 @@ function ReportAttachments({route}: ReportAttachmentsProps) { report={report} source={source} onModalClose={() => { - if (!hasDismissedModalRef.current) { - Navigation.dismissModal(); - hasDismissedModalRef.current = true; - } + Navigation.dismissModal(); // This enables Composer refocus when the attachments modal is closed by the browser navigation ComposerFocusManager.setReadyToFocus(); }} diff --git a/src/pages/workspace/WorkspaceInviteMessagePage.tsx b/src/pages/workspace/WorkspaceInviteMessagePage.tsx index ff5b7326af84..3899656424ac 100644 --- a/src/pages/workspace/WorkspaceInviteMessagePage.tsx +++ b/src/pages/workspace/WorkspaceInviteMessagePage.tsx @@ -1,9 +1,8 @@ import type {StackScreenProps} from '@react-navigation/stack'; import lodashDebounce from 'lodash/debounce'; -import React, {useEffect, useMemo, useState} from 'react'; +import React, {useCallback, useEffect, useMemo, useState} from 'react'; import {Keyboard, View} from 'react-native'; -import {withOnyx} from 'react-native-onyx'; -import type {OnyxEntry} from 'react-native-onyx'; +import {useOnyx} from 'react-native-onyx'; import type {GestureResponderEvent} from 'react-native/Libraries/Types/CoreEventTypes'; import FormProvider from '@components/Form/FormProvider'; import InputWrapper from '@components/Form/InputWrapper'; @@ -35,36 +34,16 @@ import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import INPUT_IDS from '@src/types/form/WorkspaceInviteMessageForm'; -import type {InvitedEmailsToAccountIDs, PersonalDetailsList} from '@src/types/onyx'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import AccessOrNotFoundWrapper from './AccessOrNotFoundWrapper'; import withPolicyAndFullscreenLoading from './withPolicyAndFullscreenLoading'; import type {WithPolicyAndFullscreenLoadingProps} from './withPolicyAndFullscreenLoading'; -type WorkspaceInviteMessagePageOnyxProps = { - /** All of the personal details for everyone */ - allPersonalDetails: OnyxEntry; - - /** An object containing the accountID for every invited user email */ - invitedEmailsToAccountIDsDraft: OnyxEntry; - - /** Updated workspace invite message */ - workspaceInviteMessageDraft: OnyxEntry; -}; - type WorkspaceInviteMessagePageProps = WithPolicyAndFullscreenLoadingProps & WithCurrentUserPersonalDetailsProps & - WorkspaceInviteMessagePageOnyxProps & StackScreenProps; -function WorkspaceInviteMessagePage({ - workspaceInviteMessageDraft, - invitedEmailsToAccountIDsDraft, - policy, - route, - allPersonalDetails, - currentUserPersonalDetails, -}: WorkspaceInviteMessagePageProps) { +function WorkspaceInviteMessagePage({policy, route, currentUserPersonalDetails}: WorkspaceInviteMessagePageProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); @@ -72,21 +51,28 @@ function WorkspaceInviteMessagePage({ const {inputCallbackRef, inputRef} = useAutoFocusInput(); + const [invitedEmailsToAccountIDsDraft] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_INVITE_MEMBERS_DRAFT}${route.params.policyID.toString()}`); + const [workspaceInviteMessageDraft] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_INVITE_MESSAGE_DRAFT}${route.params.policyID.toString()}`); + const [allPersonalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST); + const welcomeNoteSubject = useMemo( () => `# ${currentUserPersonalDetails?.displayName ?? ''} invited you to ${policy?.name ?? 'a workspace'}`, [policy?.name, currentUserPersonalDetails?.displayName], ); - const getDefaultWelcomeNote = () => - // workspaceInviteMessageDraft can be an empty string - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - workspaceInviteMessageDraft || - // policy?.description can be an empty string - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - Parser.htmlToMarkdown(policy?.description ?? '') || - translate('workspace.common.welcomeNote', { - workspaceName: policy?.name ?? '', - }); + const getDefaultWelcomeNote = useCallback(() => { + return ( + // workspaceInviteMessageDraft can be an empty string + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + workspaceInviteMessageDraft || + // policy?.description can be an empty string + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + Parser.htmlToMarkdown(policy?.description ?? '') || + translate('workspace.common.welcomeNote', { + workspaceName: policy?.name ?? '', + }) + ); + }, [workspaceInviteMessageDraft, policy, translate]); useEffect(() => { if (!isEmptyObject(invitedEmailsToAccountIDsDraft)) { @@ -100,14 +86,22 @@ function WorkspaceInviteMessagePage({ // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps }, []); + useEffect(() => { + if (isEmptyObject(invitedEmailsToAccountIDsDraft)) { + return; + } + setWelcomeNote(getDefaultWelcomeNote()); + }, [getDefaultWelcomeNote, invitedEmailsToAccountIDsDraft]); + const debouncedSaveDraft = lodashDebounce((newDraft: string | null) => { Policy.setWorkspaceInviteMessageDraft(route.params.policyID, newDraft); }); const sendInvitation = () => { Keyboard.dismiss(); + const policyMemberAccountIDs = Object.values(PolicyUtils.getMemberAccountIDsForWorkspace(policy?.employeeList, false, false)); // Please see https://github.com/Expensify/App/blob/main/README.md#Security for more details - Member.addMembersToWorkspace(invitedEmailsToAccountIDsDraft ?? {}, `${welcomeNoteSubject}\n\n${welcomeNote}`, route.params.policyID); + Member.addMembersToWorkspace(invitedEmailsToAccountIDsDraft ?? {}, `${welcomeNoteSubject}\n\n${welcomeNote}`, route.params.policyID, policyMemberAccountIDs); debouncedSaveDraft(null); Navigation.dismissModal(); }; @@ -220,18 +214,4 @@ function WorkspaceInviteMessagePage({ WorkspaceInviteMessagePage.displayName = 'WorkspaceInviteMessagePage'; -export default withPolicyAndFullscreenLoading( - withCurrentUserPersonalDetails( - withOnyx({ - allPersonalDetails: { - key: ONYXKEYS.PERSONAL_DETAILS_LIST, - }, - invitedEmailsToAccountIDsDraft: { - key: ({route}) => `${ONYXKEYS.COLLECTION.WORKSPACE_INVITE_MEMBERS_DRAFT}${route.params.policyID.toString()}`, - }, - workspaceInviteMessageDraft: { - key: ({route}) => `${ONYXKEYS.COLLECTION.WORKSPACE_INVITE_MESSAGE_DRAFT}${route.params.policyID.toString()}`, - }, - })(WorkspaceInviteMessagePage), - ), -); +export default withPolicyAndFullscreenLoading(withCurrentUserPersonalDetails(WorkspaceInviteMessagePage)); diff --git a/src/pages/workspace/WorkspaceMembersPage.tsx b/src/pages/workspace/WorkspaceMembersPage.tsx index 54c9ced5d6d8..52b8a06ba931 100644 --- a/src/pages/workspace/WorkspaceMembersPage.tsx +++ b/src/pages/workspace/WorkspaceMembersPage.tsx @@ -540,7 +540,7 @@ function WorkspaceMembersPage({personalDetails, route, policy, currentUserPerson shouldAlwaysShowDropdownMenu pressOnEnter - customText={translate('workspace.common.selected', {selectedNumber: selectedEmployees.length})} + customText={translate('workspace.common.selected', {count: selectedEmployees.length})} buttonSize={CONST.DROPDOWN_BUTTON_SIZE.MEDIUM} onPress={() => null} options={getBulkActionsButtonOptions()} diff --git a/src/pages/workspace/WorkspaceProfileSharePage.tsx b/src/pages/workspace/WorkspaceProfileSharePage.tsx index 16a076205ad3..082ed4d7e96a 100644 --- a/src/pages/workspace/WorkspaceProfileSharePage.tsx +++ b/src/pages/workspace/WorkspaceProfileSharePage.tsx @@ -1,12 +1,14 @@ import React, {useMemo, useRef} from 'react'; import {View} from 'react-native'; import type {ImageSourcePropType} from 'react-native'; +import expensifyLogo from '@assets/images/expensify-logo-round-transparent.png'; import ContextMenuItem from '@components/ContextMenuItem'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import * as Expensicons from '@components/Icon/Expensicons'; +import MenuItem from '@components/MenuItem'; import {useSession} from '@components/OnyxProvider'; -import QRShare from '@components/QRShare'; -import type {QRShareHandle} from '@components/QRShare/types'; +import QRShareWithDownload from '@components/QRShare/QRShareWithDownload'; +import type QRShareWithDownloadHandle from '@components/QRShare/QRShareWithDownload/types'; import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; import Text from '@components/Text'; @@ -19,6 +21,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import Clipboard from '@libs/Clipboard'; import Navigation from '@libs/Navigation/Navigation'; import * as ReportUtils from '@libs/ReportUtils'; +import shouldAllowDownloadQRCode from '@libs/shouldAllowDownloadQRCode'; import * as Url from '@libs/Url'; import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; @@ -31,7 +34,7 @@ function WorkspaceProfileSharePage({policy}: WithPolicyProps) { const StyleUtils = useStyleUtils(); const {translate} = useLocalize(); const {environmentURL} = useEnvironment(); - const qrCodeRef = useRef(null); + const qrCodeRef = useRef(null); const {shouldUseNarrowLayout} = useResponsiveLayout(); const session = useSession(); @@ -96,21 +99,14 @@ function WorkspaceProfileSharePage({policy}: WithPolicyProps) { - {/* - Right now QR code download button is not shown anymore - This is a temporary measure because right now it's broken because of the Fabric update. - We need to wait for react-native v0.74 to be released so react-native-view-shot gets fixed. - - Please see https://github.com/Expensify/App/issues/40110 to see if it can be re-enabled. - */} - @@ -126,6 +122,18 @@ function WorkspaceProfileSharePage({policy}: WithPolicyProps) { shouldLimitWidth={false} wrapperStyle={themeStyles.sectionMenuItemTopDescription} /> + {/* Remove this once https://github.com/Expensify/App/issues/19834 is done. + We shouldn't introduce platform specific code in our codebase. + This is a temporary solution while Web is not supported for the QR code download feature */} + {shouldAllowDownloadQRCode && ( + qrCodeRef.current?.download?.()} + wrapperStyle={themeStyles.sectionMenuItemTopDescription} + /> + )} diff --git a/src/pages/workspace/WorkspacesListPage.tsx b/src/pages/workspace/WorkspacesListPage.tsx index 435c62bbe73d..1dd3ee4a74d3 100755 --- a/src/pages/workspace/WorkspacesListPage.tsx +++ b/src/pages/workspace/WorkspacesListPage.tsx @@ -341,7 +341,7 @@ function WorkspacesListPage() { fallbackIcon: Expensicons.FallbackWorkspaceAvatar, policyID: policy.id, adminRoom: policyRooms?.[policy.id]?.adminRoom ?? policy.chatReportIDAdmins?.toString(), - announceRoom: policyRooms?.[policy.id]?.announceRoom ?? policy.chatReportIDAnnounce?.toString(), + announceRoom: policyRooms?.[policy.id]?.announceRoom ?? (policy.chatReportIDAnnounce ? policy.chatReportIDAnnounce?.toString() : ''), ownerAccountID: policy.ownerAccountID, role: policy.role, type: policy.type, diff --git a/src/pages/workspace/accounting/intacct/SageIntacctPrerequisitesPage.tsx b/src/pages/workspace/accounting/intacct/SageIntacctPrerequisitesPage.tsx index 4e5863d48317..1c60065a08c8 100644 --- a/src/pages/workspace/accounting/intacct/SageIntacctPrerequisitesPage.tsx +++ b/src/pages/workspace/accounting/intacct/SageIntacctPrerequisitesPage.tsx @@ -40,7 +40,7 @@ function SageIntacctPrerequisitesPage({route}: SageIntacctPrerequisitesPageProps iconRight: Expensicons.NewWindow, shouldShowRightIcon: true, onPress: () => { - fileDownload(CONST.EXPENSIFY_PACKAGE_FOR_SAGE_INTACCT, CONST.EXPENSIFY_PACKAGE_FOR_SAGE_INTACCT_FILE_NAME); + fileDownload(CONST.EXPENSIFY_PACKAGE_FOR_SAGE_INTACCT, CONST.EXPENSIFY_PACKAGE_FOR_SAGE_INTACCT_FILE_NAME, '', true); }, onSecondaryInteraction: (event: GestureResponderEvent | MouseEvent) => ReportActionContextMenu.showContextMenu(CONST.CONTEXT_MENU_TYPES.LINK, event, CONST.EXPENSIFY_PACKAGE_FOR_SAGE_INTACCT, popoverAnchor.current), diff --git a/src/pages/workspace/accounting/intacct/import/SageIntacctImportPage.tsx b/src/pages/workspace/accounting/intacct/import/SageIntacctImportPage.tsx index 75957e6cd701..92a2e8603875 100644 --- a/src/pages/workspace/accounting/intacct/import/SageIntacctImportPage.tsx +++ b/src/pages/workspace/accounting/intacct/import/SageIntacctImportPage.tsx @@ -127,7 +127,7 @@ function SageIntacctImportPage({policy}: WithPolicyProps) { 0 - ? translate('workspace.intacct.userDimensionsAdded', {dimensionsCount: sageIntacctConfig?.mappings?.dimensions?.length}) + ? translate('workspace.intacct.userDimensionsAdded', {count: sageIntacctConfig?.mappings?.dimensions?.length}) : undefined } description={translate('workspace.intacct.userDefinedDimensions')} diff --git a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx index 929870d65280..fa0b57ba207c 100644 --- a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx +++ b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx @@ -240,7 +240,7 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { shouldAlwaysShowDropdownMenu pressOnEnter buttonSize={CONST.DROPDOWN_BUTTON_SIZE.MEDIUM} - customText={translate('workspace.common.selected', {selectedNumber: selectedCategoriesArray.length})} + customText={translate('workspace.common.selected', {count: selectedCategoriesArray.length})} options={options} isSplitButton={false} style={[shouldUseNarrowLayout && styles.flexGrow1, shouldUseNarrowLayout && styles.mb3]} diff --git a/src/pages/workspace/distanceRates/CreateDistanceRatePage.tsx b/src/pages/workspace/distanceRates/CreateDistanceRatePage.tsx index b2dca63ecd34..7566f458fcff 100644 --- a/src/pages/workspace/distanceRates/CreateDistanceRatePage.tsx +++ b/src/pages/workspace/distanceRates/CreateDistanceRatePage.tsx @@ -1,8 +1,6 @@ import type {StackScreenProps} from '@react-navigation/stack'; import React, {useCallback} from 'react'; import {View} from 'react-native'; -import type {OnyxEntry} from 'react-native-onyx'; -import {withOnyx} from 'react-native-onyx'; import AmountForm from '@components/AmountForm'; import FullPageOfflineBlockingView from '@components/BlockingViews/FullPageOfflineBlockingView'; import FormProvider from '@components/Form/FormProvider'; @@ -12,6 +10,7 @@ import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; import useAutoFocusInput from '@hooks/useAutoFocusInput'; import useLocalize from '@hooks/useLocalize'; +import usePolicy from '@hooks/usePolicy'; import useThemeStyles from '@hooks/useThemeStyles'; import {getOptimisticRateName, validateRateValue} from '@libs/PolicyDistanceRatesUtils'; import Navigation from '@navigation/Navigation'; @@ -24,18 +23,14 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type SCREENS from '@src/SCREENS'; import INPUT_IDS from '@src/types/form/PolicyCreateDistanceRateForm'; import type {Rate} from '@src/types/onyx/Policy'; -import type Policy from '@src/types/onyx/Policy'; -type CreateDistanceRatePageOnyxProps = { - policy: OnyxEntry; -}; +type CreateDistanceRatePageProps = StackScreenProps; -type CreateDistanceRatePageProps = CreateDistanceRatePageOnyxProps & StackScreenProps; - -function CreateDistanceRatePage({policy, route}: CreateDistanceRatePageProps) { +function CreateDistanceRatePage({route}: CreateDistanceRatePageProps) { const styles = useThemeStyles(); const {translate, toLocaleDigit} = useLocalize(); const policyID = route.params.policyID; + const policy = usePolicy(policyID); const currency = policy?.outputCurrency ?? CONST.CURRENCY.USD; const customUnits = policy?.customUnits ?? {}; const customUnitID = customUnits[Object.keys(customUnits)[0]]?.customUnitID ?? ''; @@ -90,7 +85,7 @@ function CreateDistanceRatePage({policy, route}: CreateDistanceRatePageProps) { ({ - policy: { - key: ({route}) => `${ONYXKEYS.COLLECTION.POLICY}${route.params.policyID}`, - }, -})(CreateDistanceRatePage); +export default CreateDistanceRatePage; diff --git a/src/pages/workspace/distanceRates/PolicyDistanceRateEditPage.tsx b/src/pages/workspace/distanceRates/PolicyDistanceRateEditPage.tsx index 4128d68a0a53..30ab4b40d958 100644 --- a/src/pages/workspace/distanceRates/PolicyDistanceRateEditPage.tsx +++ b/src/pages/workspace/distanceRates/PolicyDistanceRateEditPage.tsx @@ -1,8 +1,6 @@ import type {StackScreenProps} from '@react-navigation/stack'; import React, {useCallback} from 'react'; import {Keyboard} from 'react-native'; -import {withOnyx} from 'react-native-onyx'; -import type {OnyxEntry} from 'react-native-onyx'; import AmountForm from '@components/AmountForm'; import FormProvider from '@components/Form/FormProvider'; import InputWrapperWithRef from '@components/Form/InputWrapper'; @@ -11,6 +9,7 @@ import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; import useAutoFocusInput from '@hooks/useAutoFocusInput'; import useLocalize from '@hooks/useLocalize'; +import usePolicy from '@hooks/usePolicy'; import useThemeStyles from '@hooks/useThemeStyles'; import Navigation from '@libs/Navigation/Navigation'; import {validateRateValue} from '@libs/PolicyDistanceRatesUtils'; @@ -22,27 +21,22 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type SCREENS from '@src/SCREENS'; import INPUT_IDS from '@src/types/form/PolicyDistanceRateEditForm'; -import type * as OnyxTypes from '@src/types/onyx'; -type PolicyDistanceRateEditPageOnyxProps = { - /** Policy details */ - policy: OnyxEntry; -}; +type PolicyDistanceRateEditPageProps = StackScreenProps; -type PolicyDistanceRateEditPageProps = PolicyDistanceRateEditPageOnyxProps & StackScreenProps; - -function PolicyDistanceRateEditPage({policy, route}: PolicyDistanceRateEditPageProps) { +function PolicyDistanceRateEditPage({route}: PolicyDistanceRateEditPageProps) { const styles = useThemeStyles(); const {translate, toLocaleDigit} = useLocalize(); const {inputCallbackRef} = useAutoFocusInput(); const policyID = route.params.policyID; const rateID = route.params.rateID; + const policy = usePolicy(policyID); const customUnits = policy?.customUnits ?? {}; const customUnit = customUnits[Object.keys(customUnits)[0]]; const rate = customUnit?.rates[rateID]; const currency = rate?.currency ?? CONST.CURRENCY.USD; - const currentRateValue = (parseFloat((rate?.rate ?? 0).toString()) / CONST.POLICY.CUSTOM_UNIT_RATE_BASE_OFFSET).toFixed(3); + const currentRateValue = (parseFloat((rate?.rate ?? 0).toString()) / CONST.POLICY.CUSTOM_UNIT_RATE_BASE_OFFSET).toFixed(CONST.MAX_TAX_RATE_DECIMAL_PLACES); const submitRate = (values: FormOnyxValues) => { if (currentRateValue === values.rate) { @@ -94,7 +88,7 @@ function PolicyDistanceRateEditPage({policy, route}: PolicyDistanceRateEditPageP ({ - policy: { - key: ({route}) => `${ONYXKEYS.COLLECTION.POLICY}${route.params.policyID}`, - }, -})(PolicyDistanceRateEditPage); +export default PolicyDistanceRateEditPage; diff --git a/src/pages/workspace/distanceRates/PolicyDistanceRateTaxReclaimableEditPage.tsx b/src/pages/workspace/distanceRates/PolicyDistanceRateTaxReclaimableEditPage.tsx index 21d318f2da89..f20272dd2f5c 100644 --- a/src/pages/workspace/distanceRates/PolicyDistanceRateTaxReclaimableEditPage.tsx +++ b/src/pages/workspace/distanceRates/PolicyDistanceRateTaxReclaimableEditPage.tsx @@ -9,7 +9,6 @@ import ScreenWrapper from '@components/ScreenWrapper'; import useAutoFocusInput from '@hooks/useAutoFocusInput'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; -import * as CurrencyUtils from '@libs/CurrencyUtils'; import Navigation from '@libs/Navigation/Navigation'; import {validateTaxClaimableValue} from '@libs/PolicyDistanceRatesUtils'; import type {SettingsNavigatorParamList} from '@navigation/types'; @@ -35,9 +34,8 @@ function PolicyDistanceRateTaxReclaimableEditPage({route, policy}: PolicyDistanc const customUnit = customUnits[Object.keys(customUnits)[0]]; const rate = customUnit.rates[rateID]; const currency = rate.currency ?? CONST.CURRENCY.USD; - const extraDecimals = 1; - const decimals = CurrencyUtils.getCurrencyDecimals(currency) + extraDecimals; - const currentTaxReclaimableOnValue = rate.attributes?.taxClaimablePercentage && rate.rate ? ((rate.attributes.taxClaimablePercentage * rate.rate) / 100).toFixed(decimals) : ''; + const currentTaxReclaimableOnValue = + rate.attributes?.taxClaimablePercentage && rate.rate ? ((rate.attributes.taxClaimablePercentage * rate.rate) / 100).toFixed(CONST.MAX_TAX_RATE_DECIMAL_PLACES) : ''; const submitTaxReclaimableOn = (values: FormOnyxValues) => { if (values.taxClaimableValue === currentTaxReclaimableOnValue) { @@ -88,7 +86,7 @@ function PolicyDistanceRateTaxReclaimableEditPage({route, policy}: PolicyDistanc shouldAlwaysShowDropdownMenu pressOnEnter - customText={translate('workspace.common.selected', {selectedNumber: selectedDistanceRates.length})} + customText={translate('workspace.common.selected', {count: selectedDistanceRates.length})} buttonSize={CONST.DROPDOWN_BUTTON_SIZE.MEDIUM} onPress={() => null} options={getBulkActionsButtonOptions()} diff --git a/src/pages/workspace/reportFields/ReportFieldsListValuesPage.tsx b/src/pages/workspace/reportFields/ReportFieldsListValuesPage.tsx index 937a2d5efec4..6cc1da84a063 100644 --- a/src/pages/workspace/reportFields/ReportFieldsListValuesPage.tsx +++ b/src/pages/workspace/reportFields/ReportFieldsListValuesPage.tsx @@ -258,7 +258,7 @@ function ReportFieldsListValuesPage({ shouldAlwaysShowDropdownMenu pressOnEnter buttonSize={CONST.DROPDOWN_BUTTON_SIZE.MEDIUM} - customText={translate('workspace.common.selected', {selectedNumber: selectedValuesArray.length})} + customText={translate('workspace.common.selected', {count: selectedValuesArray.length})} options={options} isSplitButton={false} style={[isSmallScreenWidth && styles.flexGrow1, isSmallScreenWidth && styles.mb3]} diff --git a/src/pages/workspace/reportFields/WorkspaceReportFieldsPage.tsx b/src/pages/workspace/reportFields/WorkspaceReportFieldsPage.tsx index e20f3e70b2f3..01465a9b8a3c 100644 --- a/src/pages/workspace/reportFields/WorkspaceReportFieldsPage.tsx +++ b/src/pages/workspace/reportFields/WorkspaceReportFieldsPage.tsx @@ -176,7 +176,7 @@ function WorkspaceReportFieldsPage({ shouldAlwaysShowDropdownMenu pressOnEnter buttonSize={CONST.DROPDOWN_BUTTON_SIZE.MEDIUM} - customText={translate('workspace.common.selected', {selectedNumber: selectedReportFields.length})} + customText={translate('workspace.common.selected', {count: selectedReportFields.length})} options={options} isSplitButton={false} style={[shouldUseNarrowLayout && styles.flexGrow1, shouldUseNarrowLayout && styles.mb3]} diff --git a/src/pages/workspace/rules/IndividualExpenseRulesSection.tsx b/src/pages/workspace/rules/IndividualExpenseRulesSection.tsx index 3acc459c79c7..7b9acc5e6ea0 100644 --- a/src/pages/workspace/rules/IndividualExpenseRulesSection.tsx +++ b/src/pages/workspace/rules/IndividualExpenseRulesSection.tsx @@ -108,7 +108,7 @@ function IndividualExpenseRulesSection({policyID}: IndividualExpenseRulesSection return ''; } - return translate('workspace.rules.individualExpenseRules.maxExpenseAgeDays', {age: policy?.maxExpenseAge}); + return translate('workspace.rules.individualExpenseRules.maxExpenseAgeDays', {count: policy?.maxExpenseAge}); }, [policy?.maxExpenseAge, translate]); const billableModeText = translate(`workspace.rules.individualExpenseRules.${policy?.defaultBillable ? 'billable' : 'nonBillable'}`); diff --git a/src/pages/workspace/tags/WorkspaceTagsPage.tsx b/src/pages/workspace/tags/WorkspaceTagsPage.tsx index 8ccf6e61eaae..ffdfdead7591 100644 --- a/src/pages/workspace/tags/WorkspaceTagsPage.tsx +++ b/src/pages/workspace/tags/WorkspaceTagsPage.tsx @@ -284,7 +284,7 @@ function WorkspaceTagsPage({route}: WorkspaceTagsPageProps) { pressOnEnter isSplitButton={false} buttonSize={CONST.DROPDOWN_BUTTON_SIZE.MEDIUM} - customText={translate('workspace.common.selected', {selectedNumber: selectedTagsArray.length})} + customText={translate('workspace.common.selected', {count: selectedTagsArray.length})} options={options} style={[shouldUseNarrowLayout && styles.flexGrow1, shouldUseNarrowLayout && styles.mb3]} isDisabled={!selectedTagsArray.length} diff --git a/src/pages/workspace/tags/WorkspaceViewTagsPage.tsx b/src/pages/workspace/tags/WorkspaceViewTagsPage.tsx index fae17a847c98..61013242c9e1 100644 --- a/src/pages/workspace/tags/WorkspaceViewTagsPage.tsx +++ b/src/pages/workspace/tags/WorkspaceViewTagsPage.tsx @@ -224,7 +224,7 @@ function WorkspaceViewTagsPage({route}: WorkspaceViewTagsProps) { pressOnEnter isSplitButton={false} buttonSize={CONST.DROPDOWN_BUTTON_SIZE.MEDIUM} - customText={translate('workspace.common.selected', {selectedNumber: selectedTagsArray.length})} + customText={translate('workspace.common.selected', {count: selectedTagsArray.length})} options={options} style={[shouldUseNarrowLayout && styles.flexGrow1, shouldUseNarrowLayout && styles.mb3]} isDisabled={!selectedTagsArray.length} diff --git a/src/pages/workspace/taxes/WorkspaceTaxesPage.tsx b/src/pages/workspace/taxes/WorkspaceTaxesPage.tsx index 7730924a6522..31e4a5e093eb 100644 --- a/src/pages/workspace/taxes/WorkspaceTaxesPage.tsx +++ b/src/pages/workspace/taxes/WorkspaceTaxesPage.tsx @@ -240,7 +240,7 @@ function WorkspaceTaxesPage({ onPress={() => {}} options={dropdownMenuOptions} buttonSize={CONST.DROPDOWN_BUTTON_SIZE.MEDIUM} - customText={translate('workspace.common.selected', {selectedNumber: selectedTaxesIDs.length})} + customText={translate('workspace.common.selected', {count: selectedTaxesIDs.length})} shouldAlwaysShowDropdownMenu pressOnEnter isSplitButton={false} diff --git a/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx b/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx index cf1fdf30c7a4..82c5bdb660bc 100644 --- a/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx +++ b/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx @@ -109,14 +109,10 @@ function WorkspaceWorkflowsPage({policy, route}: WorkspaceWorkflowsPageProps) { }, [policy, route.params.policyID, availableMembers, usedApproverEmails]); const optionItems: ToggleSettingOptionRowProps[] = useMemo(() => { - const {accountNumber, addressName, bankName, bankAccountID} = policy?.achAccount ?? {}; + const {addressName, bankName, bankAccountID} = policy?.achAccount ?? {}; const shouldShowBankAccount = !!bankAccountID && policy?.reimbursementChoice === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES; const bankIcon = getBankIcon({bankName: bankName as BankName, isCard: false, styles}); - let bankDisplayName = bankName ?? addressName; - if (accountNumber && bankDisplayName !== accountNumber) { - bankDisplayName += ` ${accountNumber.slice(-5)}`; - } const hasReimburserError = !!policy?.errorFields?.reimburser; const hasApprovalError = !!policy?.errorFields?.approvalMode; const hasDelayedSubmissionError = !!policy?.errorFields?.autoReporting ?? !!policy?.errorFields?.autoReportingFrequency; diff --git a/src/stories/Form.stories.tsx b/src/stories/Form.stories.tsx index ab29612b0556..c15fcb982239 100644 --- a/src/stories/Form.stories.tsx +++ b/src/stories/Form.stories.tsx @@ -2,6 +2,7 @@ import type {Meta, StoryFn} from '@storybook/react'; import React, {useState} from 'react'; import type {ComponentType} from 'react'; import {View} from 'react-native'; +import type {OnyxEntry} from 'react-native-onyx'; import AddressSearch from '@components/AddressSearch'; import CheckboxWithLabel from '@components/CheckboxWithLabel'; import DatePicker from '@components/DatePicker'; @@ -18,8 +19,10 @@ import * as FormActions from '@userActions/FormActions'; import CONST from '@src/CONST'; import type {OnyxFormValuesMapping} from '@src/ONYXKEYS'; import {defaultStyles} from '@src/styles'; +import type {Form} from '@src/types/form'; +import type {Network} from '@src/types/onyx'; -type FormStory = StoryFn; +type FormStory = StoryFn; type StorybookFormValues = { routingNumber?: string; @@ -32,6 +35,17 @@ type StorybookFormValues = { checkbox?: boolean; }; +type FormProviderOnyxProps = { + /** Contains the form state that must be accessed outside the component */ + formState: OnyxEntry; + + /** Contains draft values for each input in the form */ + draftValues: OnyxEntry; + + /** Information about the network */ + network: OnyxEntry; +}; + type StorybookFormErrors = Partial>; const STORYBOOK_FORM_ID = 'TestForm' as keyof OnyxFormValuesMapping; @@ -50,7 +64,7 @@ const story: Meta = { }, }; -function Template(props: FormProviderProps) { +function Template(props: FormProviderProps & FormProviderOnyxProps) { // Form consumes data from Onyx, so we initialize Onyx with the necessary data here NetworkConnection.setOfflineStatus(false); FormActions.setIsLoading(props.formID, !!props.formState?.isLoading); @@ -162,7 +176,7 @@ function Template(props: FormProviderProps) { /** * Story to exhibit the native event handlers for TextInput in the Form Component */ -function WithNativeEventHandler(props: FormProviderProps) { +function WithNativeEventHandler(props: FormProviderProps & FormProviderOnyxProps) { const [log, setLog] = useState(''); // Form consumes data from Onyx, so we initialize Onyx with the necessary data here diff --git a/src/types/onyx/SaveSearch.ts b/src/types/onyx/SaveSearch.ts index d8f8bf32f2a1..6b3a903b1639 100644 --- a/src/types/onyx/SaveSearch.ts +++ b/src/types/onyx/SaveSearch.ts @@ -1,17 +1,19 @@ +import type * as OnyxCommon from './OnyxCommon'; + /** * Model of a single saved search */ -type SaveSearchItem = { +type SaveSearchItem = OnyxCommon.OnyxValueWithOfflineFeedback<{ /** Name of the saved search */ name: string; /** Query string for the saved search */ query: string; -}; +}>; /** * Model of saved searches */ -type SaveSearch = Record; +type SaveSearch = Record; export type {SaveSearch, SaveSearchItem}; diff --git a/tests/actions/PolicyTest.ts b/tests/actions/PolicyTest.ts index 01842b48b6f4..2ede9f5e5228 100644 --- a/tests/actions/PolicyTest.ts +++ b/tests/actions/PolicyTest.ts @@ -12,7 +12,6 @@ import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; const ESH_EMAIL = 'eshgupta1217@gmail.com'; const ESH_ACCOUNT_ID = 1; -const ESH_PARTICIPANT_ANNOUNCE_ROOM: Participant = {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}; const ESH_PARTICIPANT_ADMINS_ROOM: Participant = {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}; const ESH_PARTICIPANT_EXPENSE_CHAT = {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}; const WORKSPACE_NAME = "Esh's Workspace"; @@ -37,7 +36,6 @@ describe('actions/Policy', () => { await waitForBatchedUpdates(); let adminReportID; - let announceReportID; let expenseReportID; const policyID = Policy.generatePolicyID(); @@ -75,9 +73,9 @@ describe('actions/Policy', () => { }); }); - // Three reports should be created: #announce, #admins and expense report + // Two reports should be created: #admins and expense report const workspaceReports = Object.values(allReports ?? {}).filter((report) => report?.policyID === policyID); - expect(workspaceReports.length).toBe(3); + expect(workspaceReports.length).toBe(2); workspaceReports.forEach((report) => { expect(report?.pendingFields?.addWorkspaceRoom).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); switch (report?.chatType) { @@ -86,11 +84,6 @@ describe('actions/Policy', () => { adminReportID = report.reportID; break; } - case CONST.REPORT.CHAT_TYPE.POLICY_ANNOUNCE: { - expect(report?.participants).toEqual({[ESH_ACCOUNT_ID]: ESH_PARTICIPANT_ANNOUNCE_ROOM}); - announceReportID = report.reportID; - break; - } case CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT: { expect(report?.participants).toEqual({[ESH_ACCOUNT_ID]: ESH_PARTICIPANT_EXPENSE_CHAT}); expenseReportID = report.reportID; @@ -114,13 +107,12 @@ describe('actions/Policy', () => { // Each of the three reports should have a a `CREATED` action. let adminReportActions: ReportAction[] = Object.values(reportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${adminReportID}`] ?? {}); - let announceReportActions: ReportAction[] = Object.values(reportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${announceReportID}`] ?? {}); let expenseReportActions: ReportAction[] = Object.values(reportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReportID}`] ?? {}); - let workspaceReportActions: ReportAction[] = adminReportActions.concat(announceReportActions, expenseReportActions); - [adminReportActions, announceReportActions, expenseReportActions].forEach((actions) => { + let workspaceReportActions: ReportAction[] = adminReportActions.concat(expenseReportActions); + [adminReportActions, expenseReportActions].forEach((actions) => { expect(actions.length).toBe(1); }); - [...adminReportActions, ...announceReportActions, ...expenseReportActions].forEach((reportAction) => { + [...adminReportActions, ...expenseReportActions].forEach((reportAction) => { expect(reportAction.actionName).toBe(CONST.REPORT.ACTIONS.TYPE.CREATED); expect(reportAction.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); expect(reportAction.actorAccountID).toBe(ESH_ACCOUNT_ID); @@ -174,9 +166,8 @@ describe('actions/Policy', () => { // Check if the report action pending action was cleared adminReportActions = Object.values(reportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${adminReportID}`] ?? {}); - announceReportActions = Object.values(reportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${announceReportID}`] ?? {}); expenseReportActions = Object.values(reportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReportID}`] ?? {}); - workspaceReportActions = adminReportActions.concat(announceReportActions, expenseReportActions); + workspaceReportActions = adminReportActions.concat(expenseReportActions); workspaceReportActions.forEach((reportAction) => { expect(reportAction.pendingAction).toBeFalsy(); });