diff --git a/.eslintrc.js b/.eslintrc.js index d9e25cc596f7..761a62b8314b 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -108,7 +108,7 @@ module.exports = { 'plugin:you-dont-need-lodash-underscore/all', 'plugin:prettier/recommended', ], - plugins: ['@typescript-eslint', 'jsdoc', 'you-dont-need-lodash-underscore', 'react-native-a11y', 'react', 'testing-library', 'eslint-plugin-react-compiler', 'lodash'], + plugins: ['@typescript-eslint', 'jsdoc', 'you-dont-need-lodash-underscore', 'react-native-a11y', 'react', 'testing-library', 'eslint-plugin-react-compiler', 'lodash', 'deprecation'], ignorePatterns: ['lib/**'], parser: '@typescript-eslint/parser', parserOptions: { @@ -177,6 +177,7 @@ module.exports = { // ESLint core rules 'es/no-nullish-coalescing-operators': 'off', 'es/no-optional-chaining': 'off', + 'deprecation/deprecation': 'off', // Import specific rules 'import/consistent-type-specifier-style': ['error', 'prefer-top-level'], 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/README.md b/README.md index c8faff111bae..4a691045e7c2 100644 --- a/README.md +++ b/README.md @@ -619,7 +619,30 @@ Some pointers: key to the translation file and use the arrow function version, like so: `nameOfTheKey: ({amount, dateTime}) => "User has sent " + amount + " to you on " + dateTime,`. This is because the order of the phrases might vary from one language to another. - +- When working with translations that involve plural forms, it's important to handle different cases correctly. + + For example: + - zero: Used when there are no items **(optional)**. + - one: Used when there's exactly one item. + - two: Used when there's two items. **(optional)** + - few: Used for a small number of items **(optional)**. + - many: Used for larger quantities **(optional)**. + - other: A catch-all case for other counts or variations. + + Here’s an example of how to implement plural translations: + + messages: () => ({ + zero: 'No messages', + one: 'One message', + two: 'Two messages', + few: (count) => `${count} messages`, + many: (count) => `You have ${count} messages`, + other: (count) => `You have ${count} unread messages`, + }) + + In your code, you can use the translation like this: + + `translate('common.messages', {count: 1});` ---- # Deploying diff --git a/android/app/build.gradle b/android/app/build.gradle index 833f8290e5e6..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 1009004001 - versionName "9.0.40-1" + 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/assets/images/table.svg b/assets/images/table.svg index a9cfe68f339e..36d4ced774f1 100644 --- a/assets/images/table.svg +++ b/assets/images/table.svg @@ -1,3 +1,3 @@ - + diff --git a/docs/articles/expensify-classic/bank-accounts-and-payments/bank-accounts/Enable-Global-Reimbursements.md b/docs/articles/expensify-classic/bank-accounts-and-payments/bank-accounts/Enable-Global-Reimbursements.md index b0c767fce277..37d8d8bbe42b 100644 --- a/docs/articles/expensify-classic/bank-accounts-and-payments/bank-accounts/Enable-Global-Reimbursements.md +++ b/docs/articles/expensify-classic/bank-accounts-and-payments/bank-accounts/Enable-Global-Reimbursements.md @@ -90,6 +90,10 @@ Have the employee double-check that their [default workspace](https://help.expen - **Authorized User**: The person who will process global reimbursements. The Authorized User should be the same person who manages the bank account connection in Expensify. - **User**: You can leave this section blank because the “User” is Expensify. +**Does Global Reimbursement support Sepa in the EU?** + +Global Reimbursement uses Sepa B2B to facilitate payments from EU-based accounts. Sepa Core is not supported. + {% include faq-end.md %} diff --git a/docs/articles/expensify-classic/connections/quickbooks-desktop/Connect-To-QuickBooks-Desktop.md b/docs/articles/expensify-classic/connections/quickbooks-desktop/Connect-To-QuickBooks-Desktop.md index 1fb1b09328b9..bda84eb0a49f 100644 --- a/docs/articles/expensify-classic/connections/quickbooks-desktop/Connect-To-QuickBooks-Desktop.md +++ b/docs/articles/expensify-classic/connections/quickbooks-desktop/Connect-To-QuickBooks-Desktop.md @@ -26,9 +26,7 @@ To connect QuickBooks Desktop to Expensify, you must log into QuickBooks Desktop 7. Download the Web Connector and go through the guided installation process. 8. Open the Web Connector. -9. Click on **Add an Application**. - - ![The Web Connnector Pop-up where you will need to click on Add an Application](https://help.expensify.com/assets/images/QBO_desktop_03.png){:width="100%"} +9. Download the config file when prompted during the setup process, then open it using your File Explorer. This will automatically load the application into the QuickBooks Web Connector. {% include info.html %} For this step, it is key to ensure that the correct company file is open in QuickBooks Desktop and that it is the only one open. 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/NewApp_AdHoc.mobileprovision.gpg b/ios/NewApp_AdHoc.mobileprovision.gpg index 78fb5d53d9e9..d2f181f6b7f4 100644 Binary files a/ios/NewApp_AdHoc.mobileprovision.gpg and b/ios/NewApp_AdHoc.mobileprovision.gpg differ diff --git a/ios/NewApp_AdHoc_Notification_Service.mobileprovision.gpg b/ios/NewApp_AdHoc_Notification_Service.mobileprovision.gpg index ac2c76a118d5..c0afa40ecb29 100644 Binary files a/ios/NewApp_AdHoc_Notification_Service.mobileprovision.gpg and b/ios/NewApp_AdHoc_Notification_Service.mobileprovision.gpg differ diff --git a/ios/NewExpensify.xcodeproj/project.pbxproj b/ios/NewExpensify.xcodeproj/project.pbxproj index 768062717d4b..1a29a275b956 100644 --- a/ios/NewExpensify.xcodeproj/project.pbxproj +++ b/ios/NewExpensify.xcodeproj/project.pbxproj @@ -43,7 +43,7 @@ D27CE6B77196EF3EF450EEAC /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 0D3F9E814828D91464DF9D35 /* PrivacyInfo.xcprivacy */; }; DD79042B2792E76D004484B4 /* RCTBootSplash.mm in Sources */ = {isa = PBXBuildFile; fileRef = DD79042A2792E76D004484B4 /* RCTBootSplash.mm */; }; DDCB2E57F334C143AC462B43 /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D20D83B0E39BA6D21761E72 /* ExpoModulesProvider.swift */; }; - E51DC681C7DEE40AEBDDFBFE /* BuildFile in Frameworks */ = {isa = PBXBuildFile; }; + E51DC681C7DEE40AEBDDFBFE /* (null) in Frameworks */ = {isa = PBXBuildFile; }; E9DF872D2525201700607FDC /* AirshipConfig.plist in Resources */ = {isa = PBXBuildFile; fileRef = E9DF872C2525201700607FDC /* AirshipConfig.plist */; }; ED222ED90E074A5481A854FA /* ExpensifyNeue-BoldItalic.otf in Resources */ = {isa = PBXBuildFile; fileRef = 8B28D84EF339436DBD42A203 /* ExpensifyNeue-BoldItalic.otf */; }; F0C450EA2705020500FD2970 /* colors.json in Resources */ = {isa = PBXBuildFile; fileRef = F0C450E92705020500FD2970 /* colors.json */; }; @@ -131,6 +131,7 @@ 7F3784A52C7512CF00063508 /* NewExpensifyReleaseDevelopment.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; name = NewExpensifyReleaseDevelopment.entitlements; path = NewExpensify/NewExpensifyReleaseDevelopment.entitlements; sourceTree = ""; }; 7F3784A62C7512D900063508 /* NewExpensifyReleaseAdHoc.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; name = NewExpensifyReleaseAdHoc.entitlements; path = NewExpensify/NewExpensifyReleaseAdHoc.entitlements; sourceTree = ""; }; 7F3784A72C75131000063508 /* NewExpensifyReleaseProduction.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; name = NewExpensifyReleaseProduction.entitlements; path = NewExpensify/NewExpensifyReleaseProduction.entitlements; sourceTree = ""; }; + 7F9C91352CA5EC4900FC4DC1 /* NotificationServiceExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = NotificationServiceExtension.entitlements; sourceTree = ""; }; 7F9DD8D92B2A445B005E3AFA /* ExpError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExpError.swift; sourceTree = ""; }; 7FD73C9B2B23CE9500420AF3 /* NotificationServiceExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = NotificationServiceExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 7FD73C9D2B23CE9500420AF3 /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = ""; }; @@ -175,8 +176,8 @@ buildActionMask = 2147483647; files = ( 383643682B6D4AE2005BB9AE /* DeviceCheck.framework in Frameworks */, - E51DC681C7DEE40AEBDDFBFE /* BuildFile in Frameworks */, - E51DC681C7DEE40AEBDDFBFE /* BuildFile in Frameworks */, + E51DC681C7DEE40AEBDDFBFE /* (null) in Frameworks */, + E51DC681C7DEE40AEBDDFBFE /* (null) in Frameworks */, 8744C5400E24E379441C04A4 /* libPods-NewExpensify.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -266,6 +267,7 @@ 7FD73C9C2B23CE9500420AF3 /* NotificationServiceExtension */ = { isa = PBXGroup; children = ( + 7F9C91352CA5EC4900FC4DC1 /* NotificationServiceExtension.entitlements */, 7FD73C9D2B23CE9500420AF3 /* NotificationService.swift */, 7FD73C9F2B23CE9500420AF3 /* Info.plist */, 7F9DD8D92B2A445B005E3AFA /* ExpError.swift */, @@ -1183,6 +1185,7 @@ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_ENTITLEMENTS = NotificationServiceExtension/NotificationServiceExtension.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; @@ -1347,6 +1350,7 @@ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_ENTITLEMENTS = NotificationServiceExtension/NotificationServiceExtension.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; @@ -1433,6 +1437,7 @@ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_ENTITLEMENTS = NotificationServiceExtension/NotificationServiceExtension.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; COPY_PHASE_STRIP = NO; @@ -1518,8 +1523,9 @@ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_ENTITLEMENTS = NotificationServiceExtension/NotificationServiceExtension.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = dwarf; @@ -1560,7 +1566,7 @@ PRODUCT_BUNDLE_IDENTIFIER = com.chat.expensify.chat.NotificationServiceExtension; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; - "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "(NewApp) Development: Notification Service"; + "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "(NewApp) AppStore: Notification Service"; SDKROOT = iphoneos; SKIP_INSTALL = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; @@ -1604,6 +1610,7 @@ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_ENTITLEMENTS = NotificationServiceExtension/NotificationServiceExtension.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; @@ -1683,6 +1690,7 @@ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_ENTITLEMENTS = NotificationServiceExtension/NotificationServiceExtension.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; COPY_PHASE_STRIP = NO; @@ -1761,6 +1769,7 @@ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_ENTITLEMENTS = NotificationServiceExtension/NotificationServiceExtension.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index 4eeb658f3347..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.1 + 9.0.41.2 FullStory OrgId diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index 7dc1b1416139..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.1 + 9.0.41.2 diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index 83fa9ece1deb..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.1 + 9.0.41.2 NSExtension NSExtensionPointIdentifier diff --git a/ios/NotificationServiceExtension/NotificationServiceExtension.entitlements b/ios/NotificationServiceExtension/NotificationServiceExtension.entitlements new file mode 100644 index 000000000000..f52d3207d6e3 --- /dev/null +++ b/ios/NotificationServiceExtension/NotificationServiceExtension.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.com.expensify.new + + + diff --git a/package-lock.json b/package-lock.json index 6af9c1981bc8..0ef9b9b19012 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "9.0.40-1", + "version": "9.0.41-2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "9.0.40-1", + "version": "9.0.41-2", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -50,7 +50,7 @@ "date-fns-tz": "^2.0.0", "dom-serializer": "^0.2.2", "domhandler": "^4.3.0", - "expensify-common": "2.0.84", + "expensify-common": "2.0.88", "expo": "51.0.31", "expo-av": "14.0.7", "expo-image": "1.12.15", @@ -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", @@ -24037,9 +24037,9 @@ } }, "node_modules/expensify-common": { - "version": "2.0.84", - "resolved": "https://registry.npmjs.org/expensify-common/-/expensify-common-2.0.84.tgz", - "integrity": "sha512-VistjMexRz/1u1IqjIZwGRE7aS6QOat7420Dualn+NaqMHGkfeeB4uUR3RQhCtlDbcwFBKTryIGgSrrC0N1YpA==", + "version": "2.0.88", + "resolved": "https://registry.npmjs.org/expensify-common/-/expensify-common-2.0.88.tgz", + "integrity": "sha512-4k6X6BekydYSRWkWRMB/Ts0W5Zx3BskEpLQEuxpq+cW9QIvTyFliho/dMLaXYOqS6nMQuzkjJYqfGPx9agVnOg==", "dependencies": { "awesome-phonenumber": "^5.4.0", "classnames": "2.5.0", @@ -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 deff4054ce01..aed1cf9a2c3f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "9.0.40-1", + "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.", @@ -107,7 +107,7 @@ "date-fns-tz": "^2.0.0", "dom-serializer": "^0.2.2", "domhandler": "^4.3.0", - "expensify-common": "2.0.84", + "expensify-common": "2.0.88", "expo": "51.0.31", "expo-av": "14.0.7", "expo-image": "1.12.15", @@ -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/CONST.ts b/src/CONST.ts index 33eae01ed9c8..4ca9b45f13df 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -171,7 +171,7 @@ const CONST = { }, // Note: Group and Self-DM excluded as these are not tied to a Workspace - WORKSPACE_ROOM_TYPES: [chatTypes.POLICY_ADMINS, chatTypes.POLICY_ANNOUNCE, chatTypes.DOMAIN_ALL, chatTypes.POLICY_ROOM, chatTypes.POLICY_EXPENSE_CHAT], + WORKSPACE_ROOM_TYPES: [chatTypes.POLICY_ADMINS, chatTypes.POLICY_ANNOUNCE, chatTypes.DOMAIN_ALL, chatTypes.POLICY_ROOM, chatTypes.POLICY_EXPENSE_CHAT, chatTypes.INVOICE], ANDROID_PACKAGE_NAME, WORKSPACE_ENABLE_FEATURE_REDIRECT_DELAY: 100, ANIMATED_HIGHLIGHT_ENTRY_DELAY: 50, @@ -719,7 +719,9 @@ const CONST = { PRICING: `https://www.expensify.com/pricing`, COMPANY_CARDS_HELP: 'https://help.expensify.com/articles/expensify-classic/connect-credit-cards/company-cards/Commercial-Card-Feeds', CUSTOM_REPORT_NAME_HELP_URL: 'https://help.expensify.com/articles/expensify-classic/spending-insights/Custom-Templates', + CONFIGURE_REIMBURSEMENT_SETTINGS_HELP_URL: 'https://help.expensify.com/articles/expensify-classic/workspaces/Configure-Reimbursement-Settings', COPILOT_HELP_URL: 'https://help.expensify.com/articles/expensify-classic/copilots-and-delegates/Assign-or-remove-a-Copilot', + DELAYED_SUBMISSION_HELP_URL: 'https://help.expensify.com/articles/expensify-classic/reports/Automatically-submit-employee-reports', // Use Environment.getEnvironmentURL to get the complete URL with port number DEV_NEW_EXPENSIFY_URL: 'https://dev.new.expensify.com:', OLDDOT_URLS: { @@ -1009,6 +1011,7 @@ const CONST = { MAX_PREVIEW_AVATARS: 4, MAX_ROOM_NAME_LENGTH: 99, LAST_MESSAGE_TEXT_MAX_LENGTH: 200, + MIN_LENGTH_LAST_MESSAGE_WITH_ELLIPSIS: 20, OWNER_EMAIL_FAKE: '__FAKE__', OWNER_ACCOUNT_ID_FAKE: 0, DEFAULT_REPORT_NAME: 'Chat Report', @@ -2183,7 +2186,7 @@ const CONST = { AUTO_REIMBURSEMENT_MAX_LIMIT_CENTS: 2000000, AUTO_REIMBURSEMENT_DEFAULT_LIMIT_CENTS: 10000, AUTO_APPROVE_REPORTS_UNDER_DEFAULT_CENTS: 10000, - RANDOM_AUDIT_DEFAULT_PERCENTAGE: 5, + RANDOM_AUDIT_DEFAULT_PERCENTAGE: 0.05, AUTO_REPORTING_FREQUENCIES: { INSTANT: 'instant', 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 5d4c2f40c519..dfcb42d3c4fe 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -35,7 +35,7 @@ const ROUTES = { SEARCH_CENTRAL_PANE: { route: 'search', - getRoute: ({query}: {query: SearchQueryString}) => `search?q=${encodeURIComponent(query)}` as const, + getRoute: ({query, name}: {query: SearchQueryString; name?: string}) => `search?q=${encodeURIComponent(query)}${name ? `&name=${name}` : ''}` as const, }, SEARCH_SAVED_SEARCH_RENAME: { route: 'search/saved-search/rename', @@ -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', @@ -287,7 +285,8 @@ const ROUTES = { }, EDIT_REPORT_FIELD_REQUEST: { route: 'r/:reportID/edit/policyField/:policyID/:fieldID', - getRoute: (reportID: string, policyID: string, fieldID: string, backTo?: string) => getUrlWithBackToParam(`r/${reportID}/edit/policyField/${policyID}/${fieldID}` as const, backTo), + getRoute: (reportID: string, policyID: string, fieldID: string, backTo?: string) => + getUrlWithBackToParam(`r/${reportID}/edit/policyField/${policyID}/${encodeURIComponent(fieldID)}` as const, backTo), }, REPORT_WITH_ID_DETAILS_SHARE_CODE: { route: 'r/:reportID/details/shareCode', @@ -382,9 +381,13 @@ const ROUTES = { }, }, MONEY_REQUEST_HOLD_REASON: { - route: ':type/edit/reason/:transactionID?', - getRoute: (type: ValueOf, transactionID: string, reportID: string, backTo: string) => - `${type}/edit/reason/${transactionID}?backTo=${backTo}&reportID=${reportID}` as const, + route: ':type/edit/reason/:transactionID?/:searchHash?', + getRoute: (type: ValueOf, transactionID: string, reportID: string, backTo: string, searchHash?: number) => { + const route = searchHash + ? (`${type}/edit/reason/${transactionID}/${searchHash}/?backTo=${backTo}&reportID=${reportID}` as const) + : (`${type}/edit/reason/${transactionID}/?backTo=${backTo}&reportID=${reportID}` as const); + return route; + }, }, MONEY_REQUEST_CREATE: { route: ':action/:iouType/start/:transactionID/:reportID', @@ -1574,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/AccountSwitcher.tsx b/src/components/AccountSwitcher.tsx index 71970b88eac9..8d3e311c7c61 100644 --- a/src/components/AccountSwitcher.tsx +++ b/src/components/AccountSwitcher.tsx @@ -108,7 +108,7 @@ function AccountSwitcher() { const error = ErrorUtils.getLatestErrorField({errorFields}, 'connect'); const personalDetails = PersonalDetailsUtils.getPersonalDetailByEmail(email); return createBaseMenuItem(personalDetails, error, { - badgeText: translate('delegate.role', role), + badgeText: translate('delegate.role', {role}), onPress: () => { if (isOffline) { Modal.close(() => setShouldShowOfflineModal(true)); diff --git a/src/components/AccountingConnectionConfirmationModal.tsx b/src/components/AccountingConnectionConfirmationModal.tsx index c472f215b6df..bfacd8c0bf76 100644 --- a/src/components/AccountingConnectionConfirmationModal.tsx +++ b/src/components/AccountingConnectionConfirmationModal.tsx @@ -14,11 +14,11 @@ function AccountingConnectionConfirmationModal({integrationToConnect, onCancel, return ( & 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/HeaderWithBackButton/types.ts b/src/components/HeaderWithBackButton/types.ts index 22885b6ceac5..ef480a3a9275 100644 --- a/src/components/HeaderWithBackButton/types.ts +++ b/src/components/HeaderWithBackButton/types.ts @@ -3,7 +3,7 @@ import type {StyleProp, ViewStyle} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import type {PopoverMenuItem} from '@components/PopoverMenu'; import type {Action} from '@hooks/useSingleExecution'; -import type {StepCounterParams} from '@src/languages/types'; +import type {StepCounterParams} from '@src/languages/params'; import type {AnchorPosition} from '@src/styles'; import type {Policy, Report} from '@src/types/onyx'; import type {Icon} from '@src/types/onyx/OnyxCommon'; diff --git a/src/components/ImportColumn.tsx b/src/components/ImportColumn.tsx index 49601787a207..37651e58bb79 100644 --- a/src/components/ImportColumn.tsx +++ b/src/components/ImportColumn.tsx @@ -172,7 +172,7 @@ function ImportColumn({column, columnName, columnRoles, columnIndex}: ImportColu // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps -- we don't want this effect to run again }, []); - const columnHeader = containsHeader ? column[0] : translate('spreadsheet.column', columnName); + const columnHeader = containsHeader ? column[0] : translate('spreadsheet.column', {name: columnName}); return ( diff --git a/src/components/LHNOptionsList/OptionRowLHN.tsx b/src/components/LHNOptionsList/OptionRowLHN.tsx index 353bacdc0a25..322f28aa246d 100644 --- a/src/components/LHNOptionsList/OptionRowLHN.tsx +++ b/src/components/LHNOptionsList/OptionRowLHN.tsx @@ -187,7 +187,7 @@ function OptionRowLHN({reportID, isFocused = false, onSelectRow = () => {}, opti shiftHorizontal={variables.gbrTooltipShiftHorizontal} shiftVertical={variables.composerTooltipShiftVertical} wrapperStyle={styles.quickActionTooltipWrapper} - onPressOverlay={() => User.dismissGBRTooltip()} + onHideTooltip={() => User.dismissGBRTooltip()} > diff --git a/src/components/LocaleContextProvider.tsx b/src/components/LocaleContextProvider.tsx index 383784a468d7..b677cf1e66e2 100644 --- a/src/components/LocaleContextProvider.tsx +++ b/src/components/LocaleContextProvider.tsx @@ -8,7 +8,7 @@ import * as LocalePhoneNumber from '@libs/LocalePhoneNumber'; import * as Localize from '@libs/Localize'; import * as NumberFormatUtils from '@libs/NumberFormatUtils'; import CONST from '@src/CONST'; -import type {TranslationPaths} from '@src/languages/types'; +import type {TranslationParameters, TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; import type {WithCurrentUserPersonalDetailsProps} from './withCurrentUserPersonalDetails'; import withCurrentUserPersonalDetails from './withCurrentUserPersonalDetails'; @@ -28,7 +28,7 @@ type LocaleContextProviderProps = LocaleContextProviderOnyxProps & type LocaleContextProps = { /** Returns translated string for given locale and phrase */ - translate: (phraseKey: TKey, ...phraseParameters: Localize.PhraseParameters>) => string; + translate: (path: TPath, ...parameters: TranslationParameters) => string; /** Formats number formatted according to locale and options */ numberFormat: (number: number, options?: Intl.NumberFormatOptions) => string; @@ -79,8 +79,8 @@ function LocaleContextProvider({preferredLocale, currentUserPersonalDetails, chi const translate = useMemo( () => - (phraseKey, ...phraseParameters) => - Localize.translate(locale, phraseKey, ...phraseParameters), + (path, ...parameters) => + Localize.translate(locale, path, ...parameters), [locale], ); diff --git a/src/components/MenuItem.tsx b/src/components/MenuItem.tsx index 2524658d6ffc..8dbff4287816 100644 --- a/src/components/MenuItem.tsx +++ b/src/components/MenuItem.tsx @@ -330,6 +330,9 @@ type MenuItemBaseProps = { /** Should selected item be marked with checkmark */ shouldShowSelectedItemCheck?: boolean; + + /** Handles what to do when hiding the tooltip */ + onHideTooltip?: () => void; }; type MenuItemProps = (IconProps | AvatarProps | NoIcon) & MenuItemBaseProps; @@ -416,7 +419,7 @@ function MenuItem( titleWithTooltips, displayInDefaultIconColor = false, contentFit = 'cover', - isPaneMenu = false, + isPaneMenu = true, shouldPutLeftPaddingWhenNoIcon = false, onFocus, onBlur, @@ -428,6 +431,7 @@ function MenuItem( tooltipShiftVertical = 0, renderTooltipContent, shouldShowSelectedItemCheck = false, + onHideTooltip, }: MenuItemProps, ref: PressableRef, ) { @@ -559,6 +563,7 @@ function MenuItem( shiftHorizontal={tooltipShiftHorizontal} shiftVertical={tooltipShiftVertical} shouldAutoDismiss + onHideTooltip={onHideTooltip} > diff --git a/src/components/MenuItemList.tsx b/src/components/MenuItemList.tsx index d33a17f90a5e..b2d79b6243ac 100644 --- a/src/components/MenuItemList.tsx +++ b/src/components/MenuItemList.tsx @@ -49,20 +49,9 @@ type MenuItemListProps = { /** Icon Height */ iconHeight?: number; - - /** Is this in the Pane */ - isPaneMenu?: boolean; }; -function MenuItemList({ - menuItems = [], - shouldUseSingleExecution = false, - wrapperStyle = {}, - icon = undefined, - iconWidth = undefined, - iconHeight = undefined, - isPaneMenu = false, -}: MenuItemListProps) { +function MenuItemList({menuItems = [], shouldUseSingleExecution = false, wrapperStyle = {}, icon = undefined, iconWidth = undefined, iconHeight = undefined}: MenuItemListProps) { const popoverAnchor = useRef(null); const {isExecuting, singleExecution} = useSingleExecution(); @@ -99,7 +88,6 @@ function MenuItemList({ icon={icon} iconWidth={iconWidth} iconHeight={iconHeight} - isPaneMenu={isPaneMenu} // eslint-disable-next-line react/jsx-props-no-spreading {...menuItemProps} disabled={!!menuItemProps.disabled || isExecuting} 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/ParentNavigationSubtitle.tsx b/src/components/ParentNavigationSubtitle.tsx index f60b877a5d23..997106f3e649 100644 --- a/src/components/ParentNavigationSubtitle.tsx +++ b/src/components/ParentNavigationSubtitle.tsx @@ -6,7 +6,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import Navigation from '@libs/Navigation/Navigation'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; import CONST from '@src/CONST'; -import type {ParentNavigationSummaryParams} from '@src/languages/types'; +import type {ParentNavigationSummaryParams} from '@src/languages/params'; import ROUTES from '@src/ROUTES'; import PressableWithoutFeedback from './Pressable/PressableWithoutFeedback'; import Text from './Text'; 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 ee940fe2cf1c..e6ce3080ee0a 100644 --- a/src/components/PromotedActionsBar.tsx +++ b/src/components/PromotedActionsBar.tsx @@ -37,6 +37,7 @@ type PromotedActionsType = Record P reportID?: string; isDelegateAccessRestricted: boolean; setIsNoDelegateAccessMenuVisible: (isVisible: boolean) => void; + currentSearchHash?: number; }) => PromotedAction; }; @@ -78,7 +79,7 @@ const PromotedActions = { } }, }), - hold: ({isTextHold, reportAction, reportID, isDelegateAccessRestricted, setIsNoDelegateAccessMenuVisible}) => ({ + hold: ({isTextHold, reportAction, reportID, isDelegateAccessRestricted, setIsNoDelegateAccessMenuVisible, currentSearchHash}) => ({ key: CONST.PROMOTED_ACTIONS.HOLD, icon: Expensicons.Stopwatch, text: Localize.translateLocal(`iou.${isTextHold ? 'hold' : 'unhold'}`), @@ -99,7 +100,7 @@ const PromotedActions = { return; } - ReportUtils.changeMoneyRequestHoldStatus(reportAction, ROUTES.SEARCH_REPORT.getRoute(targetedReportID)); + ReportUtils.changeMoneyRequestHoldStatus(reportAction, ROUTES.SEARCH_REPORT.getRoute({reportID: targetedReportID}), currentSearchHash); }, }), } satisfies PromotedActionsType; diff --git a/src/components/ReceiptAudit.tsx b/src/components/ReceiptAudit.tsx index bb704def1836..29439911e221 100644 --- a/src/components/ReceiptAudit.tsx +++ b/src/components/ReceiptAudit.tsx @@ -22,7 +22,7 @@ function ReceiptAudit({notes, shouldShowAuditResult}: ReceiptAuditProps) { let auditText = ''; if (notes.length > 0 && shouldShowAuditResult) { - auditText = translate('iou.receiptIssuesFound', notes.length); + auditText = translate('iou.receiptIssuesFound', {count: notes.length}); } else if (!notes.length && shouldShowAuditResult) { auditText = translate('common.verified'); } diff --git a/src/components/ReportActionItem/ExportWithDropdownMenu.tsx b/src/components/ReportActionItem/ExportWithDropdownMenu.tsx index aff868a74bc5..2f01bb0f9f46 100644 --- a/src/components/ReportActionItem/ExportWithDropdownMenu.tsx +++ b/src/components/ReportActionItem/ExportWithDropdownMenu.tsx @@ -59,7 +59,7 @@ function ExportWithDropdownMenu({ const options = [ { value: CONST.REPORT.EXPORT_OPTIONS.EXPORT_TO_INTEGRATION, - text: translate('workspace.common.exportIntegrationSelected', connectionName), + text: translate('workspace.common.exportIntegrationSelected', {connectionName}), ...optionTemplate, }, { @@ -126,7 +126,7 @@ function ExportWithDropdownMenu({ title={translate('workspace.exportAgainModal.title')} onConfirm={confirmExport} onCancel={() => setModalStatus(null)} - prompt={translate('workspace.exportAgainModal.description', report?.reportName ?? '', connectionName)} + prompt={translate('workspace.exportAgainModal.description', {connectionName, reportName: report?.reportName ?? ''})} confirmText={translate('workspace.exportAgainModal.confirmText')} cancelText={translate('workspace.exportAgainModal.cancelText')} isVisible={!!modalStatus} 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/ReportActionItem/TaskPreview.tsx b/src/components/ReportActionItem/TaskPreview.tsx index 053ad0c2c63e..a2ea7487df02 100644 --- a/src/components/ReportActionItem/TaskPreview.tsx +++ b/src/components/ReportActionItem/TaskPreview.tsx @@ -15,6 +15,7 @@ import withCurrentUserPersonalDetails from '@components/withCurrentUserPersonalD import type {WithCurrentUserPersonalDetailsProps} from '@components/withCurrentUserPersonalDetails'; import useLocalize from '@hooks/useLocalize'; import useStyleUtils from '@hooks/useStyleUtils'; +import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import ControlSelection from '@libs/ControlSelection'; import * as DeviceCapabilities from '@libs/DeviceCapabilities'; @@ -58,7 +59,9 @@ function TaskPreview({taskReportID, action, contextMenuAnchor, chatReportID, che const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const {translate} = useLocalize(); + const theme = useTheme(); const [taskReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${taskReportID}`); + // The reportAction might not contain details regarding the taskReport // Only the direct parent reportAction will contain details about the taskReport // Other linked reportActions will only contain the taskReportID and we will grab the details from there @@ -71,7 +74,7 @@ function TaskPreview({taskReportID, action, contextMenuAnchor, chatReportID, che const avatar = personalDetails?.[taskAssigneeAccountID]?.avatar ?? Expensicons.FallbackAvatar; const htmlForTaskPreview = `${taskTitle}`; const isDeletedParentAction = ReportUtils.isCanceledTaskReport(taskReport, action); - + const shouldShowGreenDotIndicator = ReportUtils.isOpenTaskReport(taskReport, action) && ReportUtils.isReportManager(taskReport); if (isDeletedParentAction) { return ${translate('parentReportAction.deletedTask')}`} />; } @@ -117,6 +120,14 @@ function TaskPreview({taskReportID, action, contextMenuAnchor, chatReportID, che ${htmlForTaskPreview}` : htmlForTaskPreview} /> + {shouldShowGreenDotIndicator && ( + + + + )} { 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/ThreeDotsMenu/index.tsx b/src/components/ThreeDotsMenu/index.tsx index da72135c6035..e44d57ab18e2 100644 --- a/src/components/ThreeDotsMenu/index.tsx +++ b/src/components/ThreeDotsMenu/index.tsx @@ -1,7 +1,6 @@ import React, {useEffect, useRef, useState} from 'react'; import {View} from 'react-native'; -import type {OnyxEntry} from 'react-native-onyx'; -import {withOnyx} from 'react-native-onyx'; +import {useOnyx} from 'react-native-onyx'; import Icon from '@components/Icon'; import * as Expensicons from '@components/Icon/Expensicons'; import PopoverMenu from '@components/PopoverMenu'; @@ -13,14 +12,8 @@ import useThemeStyles from '@hooks/useThemeStyles'; import * as Browser from '@libs/Browser'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {Modal} from '@src/types/onyx'; import type ThreeDotsMenuProps from './types'; -type ThreeDotsMenuOnyxProps = { - /** Details about any modals being used */ - modal: OnyxEntry; -}; - function ThreeDotsMenu({ iconTooltip = 'common.more', icon = Expensicons.ThreeDots, @@ -36,8 +29,9 @@ function ThreeDotsMenu({ shouldOverlay = false, shouldSetModalVisibility = true, disabled = false, - modal = {}, }: ThreeDotsMenuProps) { + const [modal] = useOnyx(ONYXKEYS.MODAL); + const theme = useTheme(); const styles = useThemeStyles(); const [isPopupMenuVisible, setPopupMenuVisible] = useState(false); @@ -114,8 +108,4 @@ function ThreeDotsMenu({ ThreeDotsMenu.displayName = 'ThreeDotsMenu'; -export default withOnyx({ - modal: { - key: ONYXKEYS.MODAL, - }, -})(ThreeDotsMenu); +export default ThreeDotsMenu; diff --git a/src/components/ThreeDotsMenu/types.ts b/src/components/ThreeDotsMenu/types.ts index 6c3618ffc3ce..86a10d08d449 100644 --- a/src/components/ThreeDotsMenu/types.ts +++ b/src/components/ThreeDotsMenu/types.ts @@ -1,18 +1,11 @@ import type {StyleProp, ViewStyle} from 'react-native'; -import type {OnyxEntry} from 'react-native-onyx'; import type {PopoverMenuItem} from '@components/PopoverMenu'; import type {TranslationPaths} from '@src/languages/types'; import type {AnchorPosition} from '@src/styles'; -import type {Modal} from '@src/types/onyx'; import type AnchorAlignment from '@src/types/utils/AnchorAlignment'; import type IconAsset from '@src/types/utils/IconAsset'; -type ThreeDotsMenuOnyxProps = { - /** Details about any modals being used */ - modal: OnyxEntry; -}; - -type ThreeDotsMenuProps = ThreeDotsMenuOnyxProps & { +type ThreeDotsMenuProps = { /** Tooltip for the popup icon */ iconTooltip?: TranslationPaths; diff --git a/src/components/Tooltip/BaseGenericTooltip/index.native.tsx b/src/components/Tooltip/BaseGenericTooltip/index.native.tsx index e42f95874b42..f586c20cba49 100644 --- a/src/components/Tooltip/BaseGenericTooltip/index.native.tsx +++ b/src/components/Tooltip/BaseGenericTooltip/index.native.tsx @@ -34,7 +34,7 @@ function BaseGenericTooltip({ }, wrapperStyle = {}, shouldUseOverlay = false, - onPressOverlay = () => {}, + onHideTooltip = () => {}, }: BaseGenericTooltipProps) { // The width of tooltip's inner content. Has to be undefined in the beginning // as a width of 0 will cause the content to be rendered of a width of 0, @@ -102,7 +102,7 @@ function BaseGenericTooltip({ return ( - {shouldUseOverlay && } + {shouldUseOverlay && } {}, + onHideTooltip = () => {}, }: BaseGenericTooltipProps) { // The width of tooltip's inner content. Has to be undefined in the beginning // as a width of 0 will cause the content to be rendered of a width of 0, @@ -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( @@ -119,7 +128,7 @@ function BaseGenericTooltip({ return ReactDOM.createPortal( <> - {shouldUseOverlay && } + {shouldUseOverlay && } void; } & Pick< SharedTooltipProps, - 'renderTooltipContent' | 'maxWidth' | 'numberOfLines' | 'text' | 'shouldForceRenderingBelow' | 'wrapperStyle' | 'anchorAlignment' | 'shouldUseOverlay' | 'onPressOverlay' + 'renderTooltipContent' | 'maxWidth' | 'numberOfLines' | 'text' | 'shouldForceRenderingBelow' | 'wrapperStyle' | 'anchorAlignment' | 'shouldUseOverlay' | 'onHideTooltip' >; // eslint-disable-next-line import/prefer-default-export diff --git a/src/components/Tooltip/EducationalTooltip/BaseEducationalTooltip.tsx b/src/components/Tooltip/EducationalTooltip/BaseEducationalTooltip.tsx index d0ff254324ae..ef5327feba31 100644 --- a/src/components/Tooltip/EducationalTooltip/BaseEducationalTooltip.tsx +++ b/src/components/Tooltip/EducationalTooltip/BaseEducationalTooltip.tsx @@ -1,7 +1,9 @@ import React, {memo, useEffect, useRef, useState} from 'react'; import type {LayoutRectangle, NativeSyntheticEvent} from 'react-native'; +import {useOnyx} from 'react-native-onyx'; import GenericTooltip from '@components/Tooltip/GenericTooltip'; import type {EducationalTooltipProps} from '@components/Tooltip/types'; +import ONYXKEYS from '@src/ONYXKEYS'; import measureTooltipCoordinate from './measureTooltipCoordinate'; type LayoutChangeEventWithTarget = NativeSyntheticEvent<{layout: LayoutRectangle; target: HTMLElement}>; @@ -10,11 +12,14 @@ type LayoutChangeEventWithTarget = NativeSyntheticEvent<{layout: LayoutRectangle * A component used to wrap an element intended for displaying a tooltip. * This tooltip would show immediately without user's interaction and hide after 5 seconds. */ -function BaseEducationalTooltip({children, shouldAutoDismiss = false, shouldRender = false, ...props}: EducationalTooltipProps) { +function BaseEducationalTooltip({children, onHideTooltip, shouldAutoDismiss = false, ...props}: EducationalTooltipProps) { const hideTooltipRef = useRef<() => void>(); const [shouldMeasure, setShouldMeasure] = useState(false); const show = useRef<() => void>(); + const [modal] = useOnyx(ONYXKEYS.MODAL); + + const shouldShow = !modal?.willAlertModalBecomeVisible && !modal?.isVisible; useEffect( () => () => { @@ -33,27 +38,38 @@ function BaseEducationalTooltip({children, shouldAutoDismiss = false, shouldRend return; } - const timerID = setTimeout(hideTooltipRef.current, 5000); + // If the modal is open, hide the tooltip immediately and clear the timeout + if (!shouldShow) { + hideTooltipRef.current(); + return; + } + + // Automatically hide tooltip after 5 seconds if shouldAutoDismiss is true + const timerID = setTimeout(() => { + hideTooltipRef.current?.(); + onHideTooltip?.(); + }, 5000); return () => { clearTimeout(timerID); }; - }, [shouldAutoDismiss]); + }, [shouldAutoDismiss, shouldShow, onHideTooltip]); useEffect(() => { - if (!shouldRender || !shouldMeasure) { + if (!shouldMeasure || !shouldShow) { return; } // When tooltip is used inside an animated view (e.g. popover), we need to wait for the animation to finish before measuring content. setTimeout(() => { show.current?.(); }, 500); - }, [shouldMeasure, shouldRender]); + }, [shouldMeasure, shouldShow]); return ( {({showTooltip, hideTooltip, updateTargetBounds}) => { // eslint-disable-next-line react-compiler/react-compiler diff --git a/src/components/Tooltip/EducationalTooltip/index.tsx b/src/components/Tooltip/EducationalTooltip/index.tsx index 03500f768dd9..a97e36a5904c 100644 --- a/src/components/Tooltip/EducationalTooltip/index.tsx +++ b/src/components/Tooltip/EducationalTooltip/index.tsx @@ -2,7 +2,11 @@ import React from 'react'; import type {TooltipExtendedProps} from '@components/Tooltip/types'; import BaseEducationalTooltip from './BaseEducationalTooltip'; -function EducationalTooltip({children, ...props}: TooltipExtendedProps) { +function EducationalTooltip({children, shouldRender = false, ...props}: TooltipExtendedProps) { + if (!shouldRender) { + return children; + } + return ( {}, + onHideTooltip = () => {}, }: GenericTooltipProps) { const {preferredLocale} = useLocalize(); const {windowWidth} = useWindowDimensions(); @@ -150,8 +150,8 @@ function GenericTooltip({ } setShouldUseOverlay(false); hideTooltip(); - onPressOverlayProp(); - }, [shouldUseOverlay, onPressOverlayProp, hideTooltip]); + onHideTooltip(); + }, [shouldUseOverlay, onHideTooltip, hideTooltip]); useImperativeHandle(TooltipRefManager.ref, () => ({hideTooltip}), [hideTooltip]); @@ -183,7 +183,7 @@ function GenericTooltip({ wrapperStyle={wrapperStyle} anchorAlignment={anchorAlignment} shouldUseOverlay={shouldUseOverlay} - onPressOverlay={onPressOverlay} + onHideTooltip={onPressOverlay} /> )} diff --git a/src/components/Tooltip/types.ts b/src/components/Tooltip/types.ts index 0462b36fa524..0924f5d46a28 100644 --- a/src/components/Tooltip/types.ts +++ b/src/components/Tooltip/types.ts @@ -40,8 +40,8 @@ type SharedTooltipProps = { /** Should render a fullscreen transparent overlay */ shouldUseOverlay?: boolean; - /** Callback to fire when the transparent overlay is pressed */ - onPressOverlay?: () => void; + /** Handles what to do when hiding the tooltip */ + onHideTooltip?: () => void; }; type GenericTooltipState = { diff --git a/src/components/VideoPlayer/BaseVideoPlayer.tsx b/src/components/VideoPlayer/BaseVideoPlayer.tsx index 14943a42a9d8..84eb988d0758 100644 --- a/src/components/VideoPlayer/BaseVideoPlayer.tsx +++ b/src/components/VideoPlayer/BaseVideoPlayer.tsx @@ -441,7 +441,7 @@ function BaseVideoPlayer({ )} - {((isLoading && !isOffline) || isBuffering) && } + {((isLoading && !isOffline) || (isBuffering && !isPlaying)) && } {isLoading && (isOffline || !isBuffering) && } {controlStatusState !== CONST.VIDEO_PLAYER.CONTROLS_STATUS.HIDE && !isLoading && (isPopoverVisible || isHovered || canUseTouchScreen) && ( ; type AllCountries = Record; /* eslint-disable max-len */ -export default { +const translations = { common: { cancel: 'Cancel', dismiss: 'Dismiss', @@ -264,7 +329,7 @@ export default { fieldRequired: 'This field is required.', requestModified: 'This request is being modified by another member.', characterLimit: ({limit}: CharacterLimitParams) => `Exceeds the maximum length of ${limit} characters`, - characterLimitExceedCounter: ({length, limit}) => `Character limit exceeded (${length}/${limit})`, + characterLimitExceedCounter: ({length, limit}: CharacterLengthLimitParams) => `Character limit exceeded (${length}/${limit})`, dateInvalid: 'Please select a valid date.', invalidDateShouldBeFuture: 'Please choose today or a future date.', invalidTimeShouldBeFuture: 'Please choose a time at least one minute ahead.', @@ -643,7 +708,7 @@ export default { shouldUseYou ? `This chat is no longer active because you are no longer a member of the ${policyName} workspace.` : `This chat is no longer active because ${displayName} is no longer a member of the ${policyName} workspace.`, - [CONST.REPORT.ARCHIVE_REASON.POLICY_DELETED]: ({policyName}: ReportArchiveReasonsPolicyDeletedParams) => + [CONST.REPORT.ARCHIVE_REASON.POLICY_DELETED]: ({policyName}: ReportArchiveReasonsInvoiceReceiverPolicyDeletedParams) => `This chat is no longer active because ${policyName} is no longer an active workspace.`, [CONST.REPORT.ARCHIVE_REASON.INVOICE_RECEIVER_POLICY_DELETED]: ({policyName}: ReportArchiveReasonsInvoiceReceiverPolicyDeletedParams) => `This chat is no longer active because ${policyName} is no longer an active workspace.`, @@ -684,13 +749,13 @@ export default { dragAndDrop: 'Drag and drop your spreadsheet here, or choose a file below. Supported formats: .csv, .txt, .xls, and .xlsx.', chooseSpreadsheet: 'Select a spreadsheet file to import. Supported formats: .csv, .txt, .xls, and .xlsx.', fileContainsHeader: 'File contains column headers', - column: (name: string) => `Column ${name}`, - fieldNotMapped: (fieldName: string) => `Oops! A required field ("${fieldName}") hasn't been mapped. Please review and try again.`, - singleFieldMultipleColumns: (fieldName: string) => `Oops! You've mapped a single field ("${fieldName}") to multiple columns. Please review and try again.`, + column: ({name}: SpreadSheetColumnParams) => `Column ${name}`, + fieldNotMapped: ({fieldName}: SpreadFieldNameParams) => `Oops! A required field ("${fieldName}") hasn't been mapped. Please review and try again.`, + singleFieldMultipleColumns: ({fieldName}: SpreadFieldNameParams) => `Oops! You've mapped a single field ("${fieldName}") to multiple columns. Please review and try again.`, importSuccessfullTitle: 'Import successful', - importCategoriesSuccessfullDescription: (categories: number) => (categories > 1 ? `${categories} categories have been added.` : '1 category has been added.'), - importMembersSuccessfullDescription: (members: number) => (members > 1 ? `${members} members have been added.` : '1 member has been added.'), - importTagsSuccessfullDescription: (tags: number) => (tags > 1 ? `${tags} tags have been added.` : '1 tag has been added.'), + importCategoriesSuccessfullDescription: ({categories}: SpreadCategoriesParams) => (categories > 1 ? `${categories} categories have been added.` : '1 category has been added.'), + importMembersSuccessfullDescription: ({members}: ImportMembersSuccessfullDescriptionParams) => (members > 1 ? `${members} members have been added.` : '1 member has been added.'), + importTagsSuccessfullDescription: ({tags}: ImportTagsSuccessfullDescriptionParams) => (tags > 1 ? `${tags} tags have been added.` : '1 tag has been added.'), importFailedTitle: 'Import failed', importFailedDescription: 'Please ensure all fields are filled out correctly and try again. If the problem persists, please reach out to Concierge.', importDescription: 'Choose which fields to map from your spreadsheet by clicking the dropdown next to each imported column below.', @@ -729,7 +794,7 @@ export default { splitBill: 'Split expense', splitScan: 'Split receipt', splitDistance: 'Split distance', - paySomeone: (name: string) => `Pay ${name ?? 'someone'}`, + paySomeone: ({name}: PaySomeoneParams = {}) => `Pay ${name ?? 'someone'}`, assignTask: 'Assign task', header: 'Quick action', trackManual: 'Track expense', @@ -753,7 +818,7 @@ export default { original: 'Original', split: 'Split', splitExpense: 'Split expense', - paySomeone: ({name}: PaySomeoneParams) => `Pay ${name ?? 'someone'}`, + paySomeone: ({name}: PaySomeoneParams = {}) => `Pay ${name ?? 'someone'}`, expense: 'Expense', categorize: 'Categorize', share: 'Share', @@ -775,7 +840,10 @@ export default { receiptScanning: 'Receipt scanning...', receiptScanInProgress: 'Receipt scan in progress', receiptScanInProgressDescription: 'Receipt scan in progress. Check back later or enter the details now.', - receiptIssuesFound: (count: number) => `${count === 1 ? 'Issue' : 'Issues'} found`, + receiptIssuesFound: () => ({ + one: 'Issue found', + other: 'Issues found', + }), fieldPending: 'Pending...', defaultRate: 'Default rate', receiptMissingDetails: 'Receipt missing details', @@ -792,19 +860,27 @@ export default { 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', @@ -819,15 +895,17 @@ export default { sendInvoice: ({amount}: RequestAmountParams) => `Send ${amount} invoice`, submitAmount: ({amount}: RequestAmountParams) => `submit ${amount}`, submittedAmount: ({formattedAmount, comment}: RequestedAmountMessageParams) => `submitted ${formattedAmount}${comment ? ` for ${comment}` : ''}`, + automaticallySubmittedAmount: ({formattedAmount}: RequestedAmountMessageParams) => + `automatically submitted ${formattedAmount} via delayed submission`, trackedAmount: ({formattedAmount, comment}: RequestedAmountMessageParams) => `tracking ${formattedAmount}${comment ? ` for ${comment}` : ''}`, splitAmount: ({amount}: SplitAmountParams) => `split ${amount}`, didSplitAmount: ({formattedAmount, comment}: DidSplitAmountMessageParams) => `split ${formattedAmount}${comment ? ` for ${comment}` : ''}`, yourSplit: ({amount}: UserSplitParams) => `Your split ${amount}`, payerOwesAmount: ({payer, amount, comment}: PayerOwesAmountParams) => `${payer} owes ${amount}${comment ? ` for ${comment}` : ''}`, payerOwes: ({payer}: PayerOwesParams) => `${payer} owes: `, - payerPaidAmount: ({payer, amount}: PayerPaidAmountParams): string => `${payer ? `${payer} ` : ''}paid ${amount}`, + payerPaidAmount: ({payer, amount}: PayerPaidAmountParams) => `${payer ? `${payer} ` : ''}paid ${amount}`, payerPaid: ({payer}: PayerPaidParams) => `${payer} paid: `, - payerSpentAmount: ({payer, amount}: PayerPaidAmountParams): string => `${payer} spent ${amount}`, + payerSpentAmount: ({payer, amount}: PayerPaidAmountParams) => `${payer} spent ${amount}`, payerSpent: ({payer}: PayerPaidParams) => `${payer} spent: `, managerApproved: ({manager}: ManagerApprovedParams) => `${manager} approved:`, managerApprovedAmount: ({manager, amount}: ManagerApprovedAmountParams) => `${manager} approved ${amount}`, @@ -903,12 +981,16 @@ export default { 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', @@ -926,7 +1008,7 @@ export default { unapprove: 'Unapprove', unapproveReport: 'Unapprove report', headsUp: 'Heads up!', - unapproveWithIntegrationWarning: (accountingIntegration: string) => + unapproveWithIntegrationWarning: ({accountingIntegration}: UnapproveWithIntegrationWarningParams) => `This report has already been exported to ${accountingIntegration}. Changes to this report in Expensify may lead to data discrepancies and Expensify Card reconciliation issues. Are you sure you want to unapprove this report?`, reimbursable: 'reimbursable', nonReimbursable: 'non-reimbursable', @@ -1310,15 +1392,16 @@ export default { availableSpend: 'Remaining limit', smartLimit: { name: 'Smart limit', - title: (formattedLimit: string) => `You can spend up to ${formattedLimit} on this card, and the limit will reset as your submitted expenses are approved.`, + title: ({formattedLimit}: ViolationsOverLimitParams) => `You can spend up to ${formattedLimit} on this card, and the limit will reset as your submitted expenses are approved.`, }, fixedLimit: { name: 'Fixed limit', - title: (formattedLimit: string) => `You can spend up to ${formattedLimit} on this card, and then it will deactivate.`, + title: ({formattedLimit}: ViolationsOverLimitParams) => `You can spend up to ${formattedLimit} on this card, and then it will deactivate.`, }, monthlyLimit: { name: 'Monthly limit', - title: (formattedLimit: string) => `You can spend up to ${formattedLimit} on this card per month. The limit will reset on the 1st day of each calendar month.`, + title: ({formattedLimit}: ViolationsOverLimitParams) => + `You can spend up to ${formattedLimit} on this card per month. The limit will reset on the 1st day of each calendar month.`, }, virtualCardNumber: 'Virtual card number', physicalCardNumber: 'Physical card number', @@ -1519,7 +1602,7 @@ export default { }, }, reportDetailsPage: { - inWorkspace: ({policyName}) => `in ${policyName}`, + inWorkspace: ({policyName}: ReportPolicyNameParams) => `in ${policyName}`, }, reportDescriptionPage: { roomDescription: 'Room description', @@ -1532,7 +1615,7 @@ export default { groupChat: { lastMemberTitle: 'Heads up!', lastMemberWarning: "Since you're the last person here, leaving will make this chat inaccessible to all users. Are you sure you want to leave?", - defaultReportName: ({displayName}: {displayName: string}) => `${displayName}'s group chat`, + defaultReportName: ({displayName}: ReportArchiveReasonsClosedParams) => `${displayName}'s group chat`, }, languagePage: { language: 'Language', @@ -1664,7 +1747,7 @@ export default { dateShouldBeBefore: ({dateString}: DateShouldBeBeforeParams) => `Date should be before ${dateString}.`, dateShouldBeAfter: ({dateString}: DateShouldBeAfterParams) => `Date should be after ${dateString}.`, hasInvalidCharacter: 'Name can only include Latin characters.', - incorrectZipFormat: (zipFormat?: string) => `Incorrect zip code format.${zipFormat ? ` Acceptable format: ${zipFormat}` : ''}`, + incorrectZipFormat: ({zipFormat}: IncorrectZipFormatParams = {}) => `Incorrect zip code format.${zipFormat ? ` Acceptable format: ${zipFormat}` : ''}`, }, }, resendValidationForm: { @@ -1681,8 +1764,8 @@ export default { succesfullyUnlinkedLogin: 'Secondary login successfully unlinked!', }, emailDeliveryFailurePage: { - ourEmailProvider: (user: OurEmailProviderParams) => - `Our email provider has temporarily suspended emails to ${user.login} due to delivery issues. To unblock your login, please follow these steps:`, + ourEmailProvider: ({login}: OurEmailProviderParams) => + `Our email provider has temporarily suspended emails to ${login} due to delivery issues. To unblock your login, please follow these steps:`, confirmThat: ({login}: ConfirmThatParams) => `Confirm that ${login} is spelled correctly and is a real, deliverable email address. `, emailAliases: 'Email aliases such as "expenses@domain.com" must have access to their own email inbox for it to be a valid Expensify login.', ensureYourEmailClient: 'Ensure your email client allows expensify.com emails. ', @@ -2199,7 +2282,10 @@ export default { testTransactions: 'Test transactions', issueAndManageCards: 'Issue and manage cards', reconcileCards: 'Reconcile cards', - selected: ({selectedNumber}) => `${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', @@ -2218,7 +2304,7 @@ export default { `You have been invited to ${workspaceName || 'a workspace'}! Download the Expensify mobile app at use.expensify.com/download to start tracking your expenses.`, subscription: 'Subscription', markAsExported: 'Mark as manually entered', - exportIntegrationSelected: (connectionName: ConnectionName) => `Export to ${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[connectionName]}`, + exportIntegrationSelected: ({connectionName}: ExportIntegrationSelectedParams) => `Export to ${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[connectionName]}`, letsDoubleCheck: "Let's double check that everything looks right.", lineItemLevel: 'Line-item level', reportLevel: 'Report level', @@ -2235,13 +2321,13 @@ export default { createNewConnection: 'Create new connection', reuseExistingConnection: 'Reuse existing connection', existingConnections: 'Existing connections', - lastSyncDate: (connectionName: string, formattedDate: string) => `${connectionName} - Last synced ${formattedDate}`, - authenticationError: (connectionName: string) => `Can’t connect to ${connectionName} due to an authentication error.`, + lastSyncDate: ({connectionName, formattedDate}: LastSyncDateParams) => `${connectionName} - Last synced ${formattedDate}`, + authenticationError: ({connectionName}: AuthenticationErrorParams) => `Can’t connect to ${connectionName} due to an authentication error.`, learnMore: 'Learn more.', memberAlternateText: 'Members can submit and approve reports.', adminAlternateText: 'Admins have full edit access to all reports and workspace settings.', auditorAlternateText: 'Auditors can view and comment on reports.', - roleName: (role?: string): string => { + roleName: ({role}: OptionalParam = {}) => { switch (role) { case CONST.POLICY.ROLE.ADMIN: return 'Admin'; @@ -2366,8 +2452,8 @@ export default { accountsSwitchDescription: 'Enabled categories will be available for members to select when creating their expenses.', trackingCategories: 'Tracking categories', trackingCategoriesDescription: 'Choose how to handle Xero tracking categories in Expensify.', - mapTrackingCategoryTo: ({categoryName}) => `Map Xero ${categoryName} to`, - mapTrackingCategoryToDescription: ({categoryName}) => `Choose where to map ${categoryName} when exporting to Xero.`, + mapTrackingCategoryTo: ({categoryName}: CategoryNameParams) => `Map Xero ${categoryName} to`, + mapTrackingCategoryToDescription: ({categoryName}: CategoryNameParams) => `Choose where to map ${categoryName} when exporting to Xero.`, customers: 'Re-bill customers', customersDescription: 'Choose whether to re-bill customers in Expensify. Your Xero customer contacts can be tagged to expenses, and will export to Xero as a sales invoice.', taxesDescription: 'Choose how to handle Xero taxes in Expensify.', @@ -2464,7 +2550,7 @@ export default { }, creditCardAccount: 'Credit card account', defaultVendor: 'Default vendor', - defaultVendorDescription: (isReimbursable: boolean): string => + defaultVendorDescription: ({isReimbursable}: DefaultVendorDescriptionParams) => `Set a default vendor that will apply to ${isReimbursable ? '' : 'non-'}reimbursable expenses that don't have a matching vendor in Sage Intacct.`, exportDescription: 'Configure how Expensify data exports to Sage Intacct.', exportPreferredExporterNote: @@ -2678,12 +2764,12 @@ export default { importJobs: 'Import projects', customers: 'customers', jobs: 'projects', - label: (importFields: string[], importType: string) => `${importFields.join(' and ')}, ${importType}`, + label: ({importFields, importType}: CustomersOrJobsLabelParams) => `${importFields.join(' and ')}, ${importType}`, }, importTaxDescription: 'Import tax groups from NetSuite.', importCustomFields: { chooseOptionBelow: 'Choose an option below:', - requiredFieldError: (fieldName: string) => `Please enter the ${fieldName}`, + requiredFieldError: ({fieldName}: RequiredFieldParams) => `Please enter the ${fieldName}`, customSegments: { title: 'Custom segments/records', addText: 'Add custom segment/record', @@ -2724,7 +2810,7 @@ export default { customRecordMappingTitle: 'How should this custom record be displayed in Expensify?', }, errors: { - uniqueFieldError: (fieldName: string) => `A custom segment/record with this ${fieldName?.toLowerCase()} already exists.`, + uniqueFieldError: ({fieldName}: RequiredFieldParams) => `A custom segment/record with this ${fieldName?.toLowerCase()} already exists.`, }, }, customLists: { @@ -2758,18 +2844,18 @@ export default { [CONST.INTEGRATION_ENTITY_MAP_TYPES.NETSUITE_DEFAULT]: { label: 'NetSuite employee default', description: 'Not imported into Expensify, applied on export', - footerContent: (importField: string) => + footerContent: ({importField}: ImportFieldParams) => `If you use ${importField} in NetSuite, we'll apply the default set on the employee record upon export to Expense Report or Journal Entry.`, }, [CONST.INTEGRATION_ENTITY_MAP_TYPES.TAG]: { label: 'Tags', description: 'Line-item level', - footerContent: (importField: string) => `${startCase(importField)} will be selectable for each individual expense on an employee's report.`, + footerContent: ({importField}: ImportFieldParams) => `${startCase(importField)} will be selectable for each individual expense on an employee's report.`, }, [CONST.INTEGRATION_ENTITY_MAP_TYPES.REPORT_FIELD]: { label: 'Report fields', description: 'Report level', - footerContent: (importField: string) => `${startCase(importField)} selection will apply to all expense on an employee's report.`, + footerContent: ({importField}: ImportFieldParams) => `${startCase(importField)} selection will apply to all expense on an employee's report.`, }, }, }, @@ -2800,8 +2886,11 @@ export default { addAUserDefinedDimension: 'Add a user-defined dimension', detailedInstructionsLink: 'View detailed instructions', detailedInstructionsRestOfSentence: ' on adding user-defined dimensions.', - userDimensionsAdded: (dimensionsCount: number) => `${dimensionsCount} ${Str.pluralize('UDD', `UDDs`, dimensionsCount)} added`, - mappingTitle: (mappingName: SageIntacctMappingName): string => { + userDimensionsAdded: () => ({ + one: '1 UDD added', + other: (count: number) => `${count} UDDs added`, + }), + mappingTitle: ({mappingName}: IntacctMappingTitleParams) => { switch (mappingName) { case CONST.SAGE_INTACCT_CONFIG.MAPPINGS.DEPARTMENTS: return 'departments'; @@ -2835,7 +2924,7 @@ export default { }, yourCardProvider: `Who's your card provider?`, enableFeed: { - title: (provider: string) => `Enable your ${provider} feed`, + title: ({provider}: GoBackMessageParams) => `Enable your ${provider} feed`, heading: 'We have a direct integration with your card issuer and can import your transaction data into Expensify quickly and accurately.\n\nTo get started, simply:', visa: `1. Visit [this help article](${CONST.COMPANY_CARDS_HELP}) for detailed instructionson how to set up your Visa Commercial Cards.\n\n2. [Contact your bank](${CONST.COMPANY_CARDS_HELP}) to verify they support a custom feed for your program, and ask them toenable it.\n\n3. *Once the feed is enabled and you have its details, continue to the next screen.*`, amex: `1. Visit [this help article](${CONST.COMPANY_CARDS_HELP}) to find out if American Express can enable a custom feed for your program.\n\n2. Once the feed is enabled, Amex will send you a production letter.\n\n3. *Once you have the feed information, continue to the next screen.*`, @@ -2882,7 +2971,7 @@ export default { card: 'Card', startTransactionDate: 'Start transaction date', cardName: 'Card name', - assignedYouCard: (assigner: string) => `${assigner} assigned you a company card! Imported transactions will appear in this chat.`, + assignedYouCard: ({assigner}: AssignedYouCardParams) => `${assigner} assigned you a company card! Imported transactions will appear in this chat.`, chooseCardFeed: 'Choose card feed', }, expensifyCard: { @@ -2928,20 +3017,21 @@ export default { deactivate: 'Deactivate card', changeCardLimit: 'Change card limit', changeLimit: 'Change limit', - smartLimitWarning: (limit: string) => `If you change this card’s limit to ${limit}, new transactions will be declined until you approve more expenses on the card.`, - monthlyLimitWarning: (limit: string) => `If you change this card’s limit to ${limit}, new transactions will be declined until next month.`, - fixedLimitWarning: (limit: string) => `If you change this card’s limit to ${limit}, new transactions will be declined.`, + smartLimitWarning: ({limit}: CharacterLimitParams) => + `If you change this card’s limit to ${limit}, new transactions will be declined until you approve more expenses on the card.`, + monthlyLimitWarning: ({limit}: CharacterLimitParams) => `If you change this card’s limit to ${limit}, new transactions will be declined until next month.`, + fixedLimitWarning: ({limit}: CharacterLimitParams) => `If you change this card’s limit to ${limit}, new transactions will be declined.`, changeCardLimitType: 'Change card limit type', changeLimitType: 'Change limit type', - changeCardSmartLimitTypeWarning: (limit: string) => + changeCardSmartLimitTypeWarning: ({limit}: CharacterLimitParams) => `If you change this card's limit type to Smart Limit, new transactions will be declined because the ${limit} unapproved limit has already been reached.`, - changeCardMonthlyLimitTypeWarning: (limit: string) => + changeCardMonthlyLimitTypeWarning: ({limit}: CharacterLimitParams) => `If you change this card's limit type to Monthly, new transactions will be declined because the ${limit} monthly limit has already been reached.`, addShippingDetails: 'Add shipping details', - issuedCard: (assignee: string) => `issued ${assignee} an Expensify Card! The card will arrive in 2-3 business days.`, - issuedCardNoShippingDetails: (assignee: string) => `issued ${assignee} an Expensify Card! The card will be shipped once shipping details are added.`, + issuedCard: ({assignee}: AssigneeParams) => `issued ${assignee} an Expensify Card! The card will arrive in 2-3 business days.`, + issuedCardNoShippingDetails: ({assignee}: AssigneeParams) => `issued ${assignee} an Expensify Card! The card will be shipped once shipping details are added.`, issuedCardVirtual: ({assignee, link}: IssueVirtualCardParams) => `issued ${assignee} a virtual ${link}! The card can be used right away.`, - addedShippingDetails: (assignee: string) => `${assignee} added shipping details. Expensify Card will arrive in 2-3 business days.`, + addedShippingDetails: ({assignee}: AssigneeParams) => `${assignee} added shipping details. Expensify Card will arrive in 2-3 business days.`, }, categories: { deleteCategories: 'Delete categories', @@ -3040,8 +3130,8 @@ export default { cardNumber: 'Card number', cardholder: 'Cardholder', cardName: 'Card name', - integrationExport: (integration: string, type: string) => `${integration} ${type} export`, - integrationExportTitleFirstPart: (integration: string) => `Choose the ${integration} account where transactions should be exported. Select a different`, + integrationExport: ({integration, type}: IntegrationExportParams) => `${integration} ${type} export`, + integrationExportTitleFirstPart: ({integration}: IntegrationExportParams) => `Choose the ${integration} account where transactions should be exported. Select a different`, integrationExportTitleLinkPart: 'export option', integrationExportTitleSecondPart: 'to change the available accounts.', lastUpdated: 'Last updated', @@ -3074,7 +3164,7 @@ export default { giveItNameInstruction: 'Give the card a name that sets it apart from the others.', updating: 'Updating...', noAccountsFound: 'No accounts found', - noAccountsFoundDescription: (connection: string) => `Please add the account in ${connection} and sync the connection again.`, + noAccountsFoundDescription: ({connection}: ConnectionParams) => `Please add the account in ${connection} and sync the connection again.`, }, workflows: { title: 'Workflows', @@ -3195,7 +3285,7 @@ export default { tagRules: 'Tag rules', approverDescription: 'Approver', importTags: 'Import tags', - importedTagsMessage: (columnCounts: number) => + importedTagsMessage: ({columnCounts}: ImportedTagsMessageParams) => `We found *${columnCounts} columns* in your spreadsheet. Select *Name* next to the column that contains tags names. You can also select *Enabled* next to the column that sets tags status.`, }, taxes: { @@ -3218,7 +3308,7 @@ export default { updateTaxClaimableFailureMessage: 'The reclaimable portion must be less than the distance rate amount.', }, deleteTaxConfirmation: 'Are you sure you want to delete this tax?', - deleteMultipleTaxConfirmation: ({taxAmount}) => `Are you sure you want to delete ${taxAmount} taxes?`, + deleteMultipleTaxConfirmation: ({taxAmount}: TaxAmountParams) => `Are you sure you want to delete ${taxAmount} taxes?`, actions: { delete: 'Delete rate', deleteMultiple: 'Delete rates', @@ -3261,7 +3351,7 @@ export default { removeWorkspaceMemberButtonTitle: 'Remove from workspace', removeGroupMemberButtonTitle: 'Remove from group', removeRoomMemberButtonTitle: 'Remove from chat', - removeMemberPrompt: ({memberName}: {memberName: string}) => `Are you sure you want to remove ${memberName}?`, + removeMemberPrompt: ({memberName}: RemoveMemberPromptParams) => `Are you sure you want to remove ${memberName}?`, removeMemberTitle: 'Remove member', transferOwner: 'Transfer owner', makeMember: 'Make member', @@ -3274,7 +3364,7 @@ export default { genericRemove: 'There was a problem removing that workspace member.', }, addedWithPrimary: 'Some members were added with their primary logins.', - invitedBySecondaryLogin: ({secondaryLogin}) => `Added by secondary login ${secondaryLogin}.`, + invitedBySecondaryLogin: ({secondaryLogin}: SecondaryLoginParams) => `Added by secondary login ${secondaryLogin}.`, membersListTitle: 'Directory of all workspace members.', importMembers: 'Import members', }, @@ -3322,8 +3412,8 @@ export default { xero: 'Xero', netsuite: 'NetSuite', intacct: 'Sage Intacct', - connectionName: (integration: ConnectionName) => { - switch (integration) { + connectionName: ({connectionName}: ConnectionNameParams) => { + switch (connectionName) { case CONST.POLICY.CONNECTIONS.NAME.QBO: return 'Quickbooks Online'; case CONST.POLICY.CONNECTIONS.NAME.XERO: @@ -3340,21 +3430,22 @@ export default { errorODIntegration: "There's an error with a connection that's been set up in Expensify Classic. ", goToODToFix: 'Go to Expensify Classic to fix this issue.', setup: 'Connect', - lastSync: (relativeDate: string) => `Last synced ${relativeDate}`, + lastSync: ({relativeDate}: LastSyncAccountingParams) => `Last synced ${relativeDate}`, import: 'Import', export: 'Export', advanced: 'Advanced', other: 'Other integrations', syncNow: 'Sync now', disconnect: 'Disconnect', - disconnectTitle: (integration?: ConnectionName): string => { - const integrationName = integration && CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[integration] ? CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[integration] : 'integration'; + disconnectTitle: ({connectionName}: OptionalParam = {}) => { + const integrationName = + connectionName && CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[connectionName] ? CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[connectionName] : 'integration'; return `Disconnect ${integrationName}`; }, - connectTitle: (integrationToConnect: ConnectionName): string => `Connect ${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[integrationToConnect] ?? 'accounting integration'}`, + connectTitle: ({connectionName}: ConnectionNameParams) => `Connect ${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[connectionName] ?? 'accounting integration'}`, - syncError: (integration?: ConnectionName): string => { - switch (integration) { + syncError: ({connectionName}: ConnectionNameParams) => { + switch (connectionName) { case CONST.POLICY.CONNECTIONS.NAME.QBO: return "Can't connect to QuickBooks Online."; case CONST.POLICY.CONNECTIONS.NAME.XERO: @@ -3380,20 +3471,18 @@ export default { [CONST.INTEGRATION_ENTITY_MAP_TYPES.REPORT_FIELD]: 'Imported as report fields', [CONST.INTEGRATION_ENTITY_MAP_TYPES.NETSUITE_DEFAULT]: 'NetSuite employee default', }, - disconnectPrompt: (currentIntegration?: ConnectionName): string => { + disconnectPrompt: ({connectionName}: OptionalParam = {}) => { const integrationName = - currentIntegration && CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[currentIntegration] - ? CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[currentIntegration] - : 'this integration'; + connectionName && CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[connectionName] ? CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[connectionName] : 'this integration'; return `Are you sure you want to disconnect ${integrationName}?`; }, - connectPrompt: (integrationToConnect: ConnectionName): string => + connectPrompt: ({connectionName}: ConnectionNameParams) => `Are you sure you want to connect ${ - CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[integrationToConnect] ?? 'this accounting integration' + CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[connectionName] ?? 'this accounting integration' }? This will remove any existing acounting connections.`, enterCredentials: 'Enter your credentials', connections: { - syncStageName: (stage: PolicyConnectionSyncStage) => { + syncStageName: ({stage}: SyncStageNameConnectionsParams) => { switch (stage) { case 'quickbooksOnlineImportCustomers': return 'Importing customers'; @@ -3530,7 +3619,7 @@ export default { chooseBankAccount: 'Choose the bank account that your Expensify Card payments will be reconciled against.', accountMatches: 'Make sure this account matches your ', settlementAccount: 'Expensify Card settlement account ', - reconciliationWorks: (lastFourPAN: string) => `(ending in ${lastFourPAN}) so Continuous Reconciliation works properly.`, + reconciliationWorks: ({lastFourPAN}: ReconciliationWorksParams) => `(ending in ${lastFourPAN}) so Continuous Reconciliation works properly.`, }, }, export: { @@ -3590,9 +3679,18 @@ export default { rate: 'Rate', addRate: 'Add rate', trackTax: 'Track tax', - deleteRates: ({count}: DistanceRateOperationsParams) => `Delete ${Str.pluralize('rate', 'rates', count)}`, - enableRates: ({count}: DistanceRateOperationsParams) => `Enable ${Str.pluralize('rate', 'rates', count)}`, - disableRates: ({count}: DistanceRateOperationsParams) => `Disable ${Str.pluralize('rate', 'rates', count)}`, + deleteRates: () => ({ + one: 'Delete rate', + other: 'Delete rates', + }), + enableRates: () => ({ + one: 'Enable rate', + other: 'Enable rates', + }), + disableRates: () => ({ + one: 'Disable rate', + other: 'Disable rates', + }), enableRate: 'Enable rate', status: 'Status', unit: 'Unit', @@ -3600,7 +3698,10 @@ export default { 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', @@ -3659,19 +3760,19 @@ export default { amountOwedText: 'This account has an outstanding balance from a previous month.\n\nDo you want to clear the balance and take over billing of this workspace?', ownerOwesAmountTitle: 'Outstanding balance', ownerOwesAmountButtonText: 'Transfer balance', - ownerOwesAmountText: ({email, amount}) => + ownerOwesAmountText: ({email, amount}: OwnerOwesAmountParams) => `The account owning this workspace (${email}) has an outstanding balance from a previous month.\n\nDo you want to transfer this amount (${amount}) in order to take over billing for this workspace? Your payment card will be charged immediately.`, subscriptionTitle: 'Take over annual subscription', subscriptionButtonText: 'Transfer subscription', - subscriptionText: ({usersCount, finalCount}) => + subscriptionText: ({usersCount, finalCount}: ChangeOwnerSubscriptionParams) => `Taking over this workspace will merge its annual subscription with your current subscription. This will increase your subscription size by ${usersCount} members making your new subscription size ${finalCount}. Would you like to continue?`, duplicateSubscriptionTitle: 'Duplicate subscription alert', duplicateSubscriptionButtonText: 'Continue', - duplicateSubscriptionText: ({email, workspaceName}) => + duplicateSubscriptionText: ({email, workspaceName}: ChangeOwnerDuplicateSubscriptionParams) => `It looks like you may be trying to take over billing for ${email}'s workspaces, but to do that, you need to be an admin on all their workspaces first.\n\nClick "Continue" if you only want to take over billing for the workspace ${workspaceName}.\n\nIf you want to take over billing for their entire subscription, please have them add you as an admin to all their workspaces first before taking over billing.`, hasFailedSettlementsTitle: 'Cannot transfer ownership', hasFailedSettlementsButtonText: 'Got it', - hasFailedSettlementsText: ({email}) => + hasFailedSettlementsText: ({email}: ChangeOwnerHasFailedSettlementsParams) => `You can't take over billing because ${email} has an overdue expensify Expensify Card settlement. Please ask them to reach out to concierge@expensify.com to resolve the issue. Then, you can take over billing for this workspace.`, failedToClearBalanceTitle: 'Failed to clear balance', failedToClearBalanceButtonText: 'OK', @@ -3685,7 +3786,7 @@ export default { }, exportAgainModal: { title: 'Careful!', - description: (reportName: string, connectionName: ConnectionName) => + description: ({reportName, connectionName}: ExportAgainModalDescriptionParams) => `The following reports have already been exported to ${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[connectionName]}:\n\n${reportName}\n\nAre you sure you want to export them again?`, confirmText: 'Yes, export again', cancelText: 'Cancel', @@ -3748,7 +3849,7 @@ export default { upgradeToUnlock: 'Unlock this feature', completed: { headline: `You've upgraded your workspace!`, - successMessage: (policyName: string) => `You've successfully upgraded your ${policyName} workspace to the Control plan!`, + successMessage: ({policyName}: ReportPolicyNameParams) => `You've successfully upgraded your ${policyName} workspace to the Control plan!`, viewSubscription: 'View your subscription', moreDetails: 'for more details.', gotIt: 'Got it, thanks', @@ -3756,8 +3857,8 @@ export default { }, restrictedAction: { restricted: 'Restricted', - actionsAreCurrentlyRestricted: ({workspaceName}) => `Actions on the ${workspaceName} workspace are currently restricted`, - workspaceOwnerWillNeedToAddOrUpdatePaymentCard: ({workspaceOwnerName}) => + actionsAreCurrentlyRestricted: ({workspaceName}: ActionsAreCurrentlyRestricted) => `Actions on the ${workspaceName} workspace are currently restricted`, + workspaceOwnerWillNeedToAddOrUpdatePaymentCard: ({workspaceOwnerName}: WorkspaceOwnerWillNeedToAddOrUpdatePaymentCardParams) => `Workspace owner, ${workspaceOwnerName} will need to add or update the payment card on file to unlock new workspace activity.`, youWillNeedToAddOrUpdatePaymentCard: "You'll need to add or update the payment card on file to unlock new workspace activity.", addPaymentCardToUnlock: 'Add a payment card to unlock!', @@ -3778,7 +3879,10 @@ export default { maxAge: 'Max age', maxExpenseAge: 'Max expense age', maxExpenseAgeDescription: 'Flag spend older than a specific number of days.', - maxExpenseAgeDays: (age: number) => `${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', @@ -3815,26 +3919,26 @@ export default { randomReportAuditDescription: 'Require that some reports be manually approved, even if eligible for auto-approval.', autoPayApprovedReportsTitle: 'Auto-pay approved reports', autoPayApprovedReportsSubtitle: 'Configure which expense reports are eligible for auto-pay.', - autoPayApprovedReportsLimitError: (currency?: string) => `Please enter an amount less than ${currency ?? ''}20,000`, + autoPayApprovedReportsLimitError: ({currency}: AutoPayApprovedReportsLimitErrorParams = {}) => `Please enter an amount less than ${currency ?? ''}20,000`, autoPayApprovedReportsLockedSubtitle: 'Go to more features and enable workflows, then add payments to unlock this feature.', autoPayReportsUnderTitle: 'Auto-pay reports under', autoPayReportsUnderDescription: 'Fully compliant expense reports under this amount will be automatically paid. ', unlockFeatureGoToSubtitle: 'Go to', - unlockFeatureEnableWorkflowsSubtitle: (featureName: string) => `and enable workflows, then add ${featureName} to unlock this feature.`, - enableFeatureSubtitle: (featureName: string) => `and enable ${featureName} to unlock this feature.`, + unlockFeatureEnableWorkflowsSubtitle: ({featureName}: FeatureNameParams) => `and enable workflows, then add ${featureName} to unlock this feature.`, + enableFeatureSubtitle: ({featureName}: FeatureNameParams) => `and enable ${featureName} to unlock this feature.`, }, categoryRules: { title: 'Category rules', approver: 'Approver', requireDescription: 'Require description', descriptionHint: 'Description hint', - descriptionHintDescription: (categoryName: string) => + descriptionHintDescription: ({categoryName}: CategoryNameParams) => `Remind employees to provide additional information for “${categoryName}” spend. This hint appears in the description field on expenses.`, descriptionHintLabel: 'Hint', descriptionHintSubtitle: 'Pro-tip: The shorter the better!', maxAmount: 'Max amount', flagAmountsOver: 'Flag amounts over', - flagAmountsOverDescription: (categoryName) => `Applies to the category “${categoryName}”.`, + flagAmountsOverDescription: ({categoryName}: CategoryNameParams) => `Applies to the category “${categoryName}”.`, flagAmountsOverSubtitle: 'This overrides the max amount for all expenses.', expenseLimitTypes: { expense: 'Individual expense', @@ -3844,7 +3948,7 @@ export default { }, requireReceiptsOver: 'Require receipts over', requireReceiptsOverList: { - default: (defaultAmount: string) => `${defaultAmount} ${CONST.DOT_SEPARATOR} Default`, + default: ({defaultAmount}: DefaultAmountParams) => `${defaultAmount} ${CONST.DOT_SEPARATOR} Default`, never: 'Never require receipts', always: 'Always require receipts', }, @@ -3907,8 +4011,8 @@ export default { }, }, workspaceActions: { - renamedWorkspaceNameAction: ({oldName, newName}) => `updated the name of this workspace from ${oldName} to ${newName}`, - removedFromApprovalWorkflow: ({submittersNames}: {submittersNames: string[]}) => { + renamedWorkspaceNameAction: ({oldName, newName}: RenamedRoomActionParams) => `updated the name of this workspace from ${oldName} to ${newName}`, + removedFromApprovalWorkflow: ({submittersNames}: RemovedFromApprovalWorkflowParams) => { let joinedNames = ''; if (submittersNames.length === 1) { joinedNames = submittersNames[0]; @@ -3917,9 +4021,10 @@ export default { } 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: { @@ -3961,7 +4066,7 @@ export default { deleteConfirmation: 'Are you sure you want to delete this task?', }, statementPage: { - title: (year, monthName) => `${monthName} ${year} statement`, + title: ({year, monthName}: StatementTitleParams) => `${monthName} ${year} statement`, generatingPDF: "We're generating your PDF right now. Please check back soon!", }, keyboardShortcutsPage: { @@ -4011,8 +4116,8 @@ export default { filtersHeader: 'Filters', filters: { date: { - before: (date?: string) => `Before ${date ?? ''}`, - after: (date?: string) => `After ${date ?? ''}`, + before: ({date}: OptionalParam = {}) => `Before ${date ?? ''}`, + after: ({date}: OptionalParam = {}) => `After ${date ?? ''}`, }, status: 'Status', keyword: 'Keyword', @@ -4022,9 +4127,9 @@ export default { pinned: 'Pinned', unread: 'Unread', amount: { - lessThan: (amount?: string) => `Less than ${amount ?? ''}`, - greaterThan: (amount?: string) => `Greater than ${amount ?? ''}`, - between: (greaterThan: string, lessThan: string) => `Between ${greaterThan} and ${lessThan}`, + lessThan: ({amount}: OptionalParam = {}) => `Less than ${amount ?? ''}`, + greaterThan: ({amount}: OptionalParam = {}) => `Greater than ${amount ?? ''}`, + between: ({greaterThan, lessThan}: FiltersAmountBetweenParams) => `Between ${greaterThan} and ${lessThan}`, }, current: 'Current', past: 'Past', @@ -4144,7 +4249,7 @@ export default { nonReimbursableLink: 'View company card expenses.', pending: ({label}: ExportedToIntegrationParams) => `started exporting this report to ${label}...`, }, - integrationsMessage: (errorMessage: string, label: string) => `failed to export this report to ${label} ("${errorMessage}").`, + integrationsMessage: ({errorMessage, label}: IntegrationSyncFailedParams) => `failed to export this report to ${label} ("${errorMessage}").`, managerAttachReceipt: `added a receipt`, managerDetachReceipt: `removed a receipt`, markedReimbursed: ({amount, currency}: MarkedReimbursedParams) => `paid ${currency}${amount} elsewhere`, @@ -4161,10 +4266,10 @@ export default { stripePaid: ({amount, currency}: StripePaidParams) => `paid ${currency}${amount}`, takeControl: `took control`, unapproved: ({amount, currency}: UnapprovedParams) => `unapproved ${currency}${amount}`, - integrationSyncFailed: (label: string, errorMessage: string) => `failed to sync with ${label} ("${errorMessage}")`, - addEmployee: (email: string, role: string) => `added ${email} as ${role === 'user' ? 'member' : 'admin'}`, - updateRole: (email: string, currentRole: string, newRole: string) => `updated the role of ${email} from ${currentRole} to ${newRole}`, - removeMember: (email: string, role: string) => `removed ${role} ${email}`, + integrationSyncFailed: ({label, errorMessage}: IntegrationSyncFailedParams) => `failed to sync with ${label} ("${errorMessage}")`, + addEmployee: ({email, role}: AddEmployeeParams) => `added ${email} as ${role === 'user' ? 'member' : 'admin'}`, + updateRole: ({email, currentRole, newRole}: UpdateRoleParams) => `updated the role of ${email} from ${currentRole} to ${newRole}`, + removeMember: ({email, role}: AddEmployeeParams) => `removed ${role} ${email}`, }, }, }, @@ -4385,7 +4490,7 @@ export default { allTagLevelsRequired: 'All tags required', autoReportedRejectedExpense: ({rejectReason, rejectedBy}: ViolationsAutoReportedRejectedExpenseParams) => `${rejectedBy} rejected this expense with the comment "${rejectReason}"`, billableExpense: 'Billable no longer valid', - cashExpenseWithNoReceipt: ({formattedLimit}: ViolationsCashExpenseWithNoReceiptParams) => `Receipt required${formattedLimit ? ` over ${formattedLimit}` : ''}`, + cashExpenseWithNoReceipt: ({formattedLimit}: ViolationsCashExpenseWithNoReceiptParams = {}) => `Receipt required${formattedLimit ? ` over ${formattedLimit}` : ''}`, categoryOutOfPolicy: 'Category no longer valid', conversionSurcharge: ({surcharge}: ViolationsConversionSurchargeParams) => `Applied ${surcharge}% conversion surcharge`, customUnitOutOfPolicy: 'Rate not valid for this workspace', @@ -4396,8 +4501,8 @@ export default { maxAge: ({maxAge}: ViolationsMaxAgeParams) => `Date older than ${maxAge} days`, missingCategory: 'Missing category', missingComment: 'Description required for selected category', - missingTag: ({tagName}: ViolationsMissingTagParams) => `Missing ${tagName ?? 'tag'}`, - modifiedAmount: ({type, displayPercentVariance}: ViolationsModifiedAmountParams): string => { + missingTag: ({tagName}: ViolationsMissingTagParams = {}) => `Missing ${tagName ?? 'tag'}`, + modifiedAmount: ({type, displayPercentVariance}: ViolationsModifiedAmountParams) => { switch (type) { case 'distance': return 'Amount differs from calculated distance'; @@ -4445,10 +4550,10 @@ export default { return ''; }, smartscanFailed: 'Receipt scanning failed. Enter details manually.', - someTagLevelsRequired: ({tagName}: ViolationsTagOutOfPolicyParams) => `Missing ${tagName ?? 'Tag'}`, - tagOutOfPolicy: ({tagName}: ViolationsTagOutOfPolicyParams) => `${tagName ?? 'Tag'} no longer valid`, + someTagLevelsRequired: ({tagName}: ViolationsTagOutOfPolicyParams = {}) => `Missing ${tagName ?? 'Tag'}`, + tagOutOfPolicy: ({tagName}: ViolationsTagOutOfPolicyParams = {}) => `${tagName ?? 'Tag'} no longer valid`, taxAmountChanged: 'Tax amount was modified', - taxOutOfPolicy: ({taxName}: ViolationsTaxOutOfPolicyParams) => `${taxName ?? 'Tax'} no longer valid`, + taxOutOfPolicy: ({taxName}: ViolationsTaxOutOfPolicyParams = {}) => `${taxName ?? 'Tax'} no longer valid`, taxRateChanged: 'Tax rate was modified', taxRequired: 'Missing tax rate', none: 'None', @@ -4465,7 +4570,7 @@ export default { hold: 'Hold', }, reportViolations: { - [CONST.REPORT_VIOLATIONS.FIELD_REQUIRED]: (fieldName: string) => `${fieldName} is required`, + [CONST.REPORT_VIOLATIONS.FIELD_REQUIRED]: ({fieldName}: RequiredFieldParams) => `${fieldName} is required`, }, violationDismissal: { rter: { @@ -4520,12 +4625,12 @@ export default { authenticatePaymentCard: 'Authenticate payment card', mobileReducedFunctionalityMessage: 'You can’t make changes to your subscription in the mobile app.', badge: { - freeTrial: ({numOfDays}) => `Free trial: ${numOfDays} ${numOfDays === 1 ? 'day' : 'days'} left`, + freeTrial: ({numOfDays}: BadgeFreeTrialParams) => `Free trial: ${numOfDays} ${numOfDays === 1 ? 'day' : 'days'} left`, }, billingBanner: { policyOwnerAmountOwed: { title: 'Your payment info is outdated', - subtitle: ({date}) => `Update your payment card by ${date} to continue using all of your favorite features.`, + subtitle: ({date}: BillingBannerSubtitleWithDateParams) => `Update your payment card by ${date} to continue using all of your favorite features.`, }, policyOwnerAmountOwedOverdue: { title: 'Your payment info is outdated', @@ -4533,7 +4638,7 @@ export default { }, policyOwnerUnderInvoicing: { title: 'Your payment info is outdated', - subtitle: ({date}) => `Your payment is past due. Please pay your invoice by ${date} to avoid service interruption.`, + subtitle: ({date}: BillingBannerSubtitleWithDateParams) => `Your payment is past due. Please pay your invoice by ${date} to avoid service interruption.`, }, policyOwnerUnderInvoicingOverdue: { title: 'Your payment info is outdated', @@ -4541,22 +4646,22 @@ export default { }, billingDisputePending: { title: 'Your card couldn’t be charged', - subtitle: ({amountOwed, cardEnding}) => + subtitle: ({amountOwed, cardEnding}: BillingBannerDisputePendingParams) => `You disputed the ${amountOwed} charge on the card ending in ${cardEnding}. Your account will be locked until the dispute is resolved with your bank.`, }, cardAuthenticationRequired: { title: 'Your card couldn’t be charged', - subtitle: ({cardEnding}) => + subtitle: ({cardEnding}: BillingBannerCardAuthenticationRequiredParams) => `Your payment card hasn’t been fully authenticated. Please complete the authentication process to activate your payment card ending in ${cardEnding}.`, }, insufficientFunds: { title: 'Your card couldn’t be charged', - subtitle: ({amountOwed}) => + subtitle: ({amountOwed}: BillingBannerInsufficientFundsParams) => `Your payment card was declined due to insufficient funds. Please retry or add a new payment card to clear your ${amountOwed} outstanding balance.`, }, cardExpired: { title: 'Your card couldn’t be charged', - subtitle: ({amountOwed}) => `Your payment card expired. Please add a new payment card to clear your ${amountOwed} outstanding balance.`, + subtitle: ({amountOwed}: BillingBannerCardExpiredParams) => `Your payment card expired. Please add a new payment card to clear your ${amountOwed} outstanding balance.`, }, cardExpireSoon: { title: 'Your card is expiring soon', @@ -4570,7 +4675,7 @@ export default { title: 'Your card couldn’t be charged', subtitle: 'Before retrying, please call your bank directly to authorize Expensify charges and remove any holds. Otherwise, try adding a different payment card.', }, - cardOnDispute: ({amountOwed, cardEnding}) => + cardOnDispute: ({amountOwed, cardEnding}: BillingBannerCardOnDisputeParams) => `You disputed the ${amountOwed} charge on the card ending in ${cardEnding}. Your account will be locked until the dispute is resolved with your bank.`, preTrial: { title: 'Start a free trial', @@ -4579,7 +4684,7 @@ export default { subtitleEnd: 'so your team can start expensing.', }, trialStarted: { - title: ({numOfDays}) => `Free trial: ${numOfDays} ${numOfDays === 1 ? 'day' : 'days'} left!`, + title: ({numOfDays}: TrialStartedTitleParams) => `Free trial: ${numOfDays} ${numOfDays === 1 ? 'day' : 'days'} left!`, subtitle: 'Add a payment card to continue using all of your favorite features.', }, trialEnded: { @@ -4591,9 +4696,9 @@ export default { title: 'Payment', subtitle: 'Add a card to pay for your Expensify subscription.', addCardButton: 'Add payment card', - cardNextPayment: ({nextPaymentDate}) => `Your next payment date is ${nextPaymentDate}.`, - cardEnding: ({cardNumber}) => `Card ending in ${cardNumber}`, - cardInfo: ({name, expiration, currency}) => `Name: ${name}, Expiration: ${expiration}, Currency: ${currency}`, + cardNextPayment: ({nextPaymentDate}: CardNextPaymentParams) => `Your next payment date is ${nextPaymentDate}.`, + cardEnding: ({cardNumber}: CardEndingParams) => `Card ending in ${cardNumber}`, + cardInfo: ({name, expiration, currency}: CardInfoParams) => `Name: ${name}, Expiration: ${expiration}, Currency: ${currency}`, changeCard: 'Change payment card', changeCurrency: 'Change payment currency', cardNotFound: 'No payment card added', @@ -4612,8 +4717,8 @@ export default { title: 'Your plan', collect: { title: 'Collect', - priceAnnual: ({lower, upper}) => `From ${lower}/active member with the Expensify Card, ${upper}/active member without the Expensify Card.`, - pricePayPerUse: ({lower, upper}) => `From ${lower}/active member with the Expensify Card, ${upper}/active member without the Expensify Card.`, + priceAnnual: ({lower, upper}: YourPlanPriceParams) => `From ${lower}/active member with the Expensify Card, ${upper}/active member without the Expensify Card.`, + pricePayPerUse: ({lower, upper}: YourPlanPriceParams) => `From ${lower}/active member with the Expensify Card, ${upper}/active member without the Expensify Card.`, benefit1: 'Unlimited SmartScans and distance tracking', benefit2: 'Expensify Cards with Smart Limits', benefit3: 'Bill pay and invoicing', @@ -4624,8 +4729,8 @@ export default { }, control: { title: 'Control', - priceAnnual: ({lower, upper}) => `From ${lower}/active member with the Expensify Card, ${upper}/active member without the Expensify Card.`, - pricePayPerUse: ({lower, upper}) => `From ${lower}/active member with the Expensify Card, ${upper}/active member without the Expensify Card.`, + priceAnnual: ({lower, upper}: YourPlanPriceParams) => `From ${lower}/active member with the Expensify Card, ${upper}/active member without the Expensify Card.`, + pricePayPerUse: ({lower, upper}: YourPlanPriceParams) => `From ${lower}/active member with the Expensify Card, ${upper}/active member without the Expensify Card.`, benefit1: 'Everything in Collect, plus:', benefit2: 'NetSuite and Sage Intacct integrations', benefit3: 'Certinia and Workday sync', @@ -4656,10 +4761,10 @@ export default { note: 'Note: An active member is anyone who has created, edited, submitted, approved, reimbursed, or exported expense data tied to your company workspace.', confirmDetails: 'Confirm your new annual subscription details:', subscriptionSize: 'Subscription size', - activeMembers: ({size}) => `${size} active members/month`, + activeMembers: ({size}: SubscriptionSizeParams) => `${size} active members/month`, subscriptionRenews: 'Subscription renews', youCantDowngrade: 'You can’t downgrade during your annual subscription.', - youAlreadyCommitted: ({size, date}) => + youAlreadyCommitted: ({size, date}: SubscriptionCommitmentParams) => `You already committed to an annual subscription size of ${size} active members per month until ${date}. You can switch to a pay-per-use subscription on ${date} by disabling auto-renew.`, error: { size: 'Please enter a valid subscription size.', @@ -4676,13 +4781,13 @@ export default { title: 'Subscription settings', autoRenew: 'Auto-renew', autoIncrease: 'Auto-increase annual seats', - saveUpTo: ({amountWithCurrency}) => `Save up to ${amountWithCurrency}/month per active member`, + saveUpTo: ({amountWithCurrency}: SubscriptionSettingsSaveUpToParams) => `Save up to ${amountWithCurrency}/month per active member`, automaticallyIncrease: 'Automatically increase your annual seats to accommodate for active members that exceed your subscription size. Note: This will extend your annual subscription end date.', disableAutoRenew: 'Disable auto-renew', helpUsImprove: 'Help us improve Expensify', whatsMainReason: "What's the main reason you're disabling auto-renew?", - renewsOn: ({date}) => `Renews on ${date}.`, + renewsOn: ({date}: SubscriptionSettingsRenewsOnParams) => `Renews on ${date}.`, }, requestEarlyCancellation: { title: 'Request early cancellation', @@ -4731,7 +4836,7 @@ export default { addCopilot: 'Add copilot', membersCanAccessYourAccount: 'These members can access your account:', youCanAccessTheseAccounts: 'You can access these accounts via the account switcher:', - role: (role?: string): string => { + role: ({role}: OptionalParam = {}) => { switch (role) { case CONST.DELEGATE_ROLE.ALL: return 'Full'; @@ -4742,10 +4847,11 @@ export default { } }, genericError: 'Oops, something went wrong. Please try again.', + onBehalfOfMessage: ({delegator}: DelegatorParams) => `on behalf of ${delegator}`, accessLevel: 'Access level', confirmCopilot: 'Confirm your copilot below.', accessLevelDescription: 'Choose an access level below. Both Full and Limited access allow copilots to view all conversations and expenses.', - roleDescription: (role?: string): string => { + roleDescription: ({role}: OptionalParam = {}) => { switch (role) { case CONST.DELEGATE_ROLE.ALL: return 'Allow another member to take all actions in your account, on your behalf. Includes chat, submissions, approvals, payments, settings updates, and more.'; @@ -4771,9 +4877,9 @@ export default { nothingToPreview: 'Nothing to preview', editJson: 'Edit JSON:', preview: 'Preview:', - missingProperty: ({propertyName}) => `Missing ${propertyName}`, - invalidProperty: ({propertyName, expectedType}) => `Invalid property: ${propertyName} - Expected: ${expectedType}`, - invalidValue: ({expectedValues}) => `Invalid value - Expected: ${expectedValues}`, + missingProperty: ({propertyName}: MissingPropertyParams) => `Missing ${propertyName}`, + invalidProperty: ({propertyName, expectedType}: InvalidPropertyParams) => `Invalid property: ${propertyName} - Expected: ${expectedType}`, + invalidValue: ({expectedValues}: InvalidValueParams) => `Invalid value - Expected: ${expectedValues}`, missingValue: 'Missing value', createReportAction: 'Create Report Action', reportAction: 'Report Action', @@ -4788,4 +4894,6 @@ export default { time: 'Time', none: 'None', }, -} satisfies TranslationBase; +}; + +export default translations satisfies TranslationDeepObject; diff --git a/src/languages/es.ts b/src/languages/es.ts index 4e63f99357e7..cb19b091b058 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -1,45 +1,85 @@ -import {Str} from 'expensify-common'; import CONST from '@src/CONST'; -import type {ConnectionName, PolicyConnectionSyncStage, SageIntacctMappingName} from '@src/types/onyx/Policy'; +import type en from './en'; import type { AccountOwnerParams, + ActionsAreCurrentlyRestricted, + AddEmployeeParams, AddressLineParams, AdminCanceledRequestParams, AlreadySignedInParams, ApprovalWorkflowErrorParams, ApprovedAmountParams, AssignCardParams, + AssignedYouCardParams, + AssigneeParams, + AuthenticationErrorParams, + AutoPayApprovedReportsLimitErrorParams, + BadgeFreeTrialParams, BeginningOfChatHistoryAdminRoomPartOneParams, BeginningOfChatHistoryAnnounceRoomPartOneParams, BeginningOfChatHistoryAnnounceRoomPartTwo, BeginningOfChatHistoryDomainRoomPartOneParams, + BillingBannerCardAuthenticationRequiredParams, + BillingBannerCardExpiredParams, + BillingBannerCardOnDisputeParams, + BillingBannerDisputePendingParams, + BillingBannerInsufficientFundsParams, + BillingBannerSubtitleWithDateParams, CanceledRequestParams, + CardEndingParams, + CardInfoParams, + CardNextPaymentParams, + CategoryNameParams, ChangeFieldParams, + ChangeOwnerDuplicateSubscriptionParams, + ChangeOwnerHasFailedSettlementsParams, + ChangeOwnerSubscriptionParams, ChangePolicyParams, ChangeTypeParams, + CharacterLengthLimitParams, CharacterLimitParams, CompanyCardFeedNameParams, - ConfirmHoldExpenseParams, ConfirmThatParams, + ConnectionNameParams, + ConnectionParams, + CustomersOrJobsLabelParams, + DateParams, DateShouldBeAfterParams, DateShouldBeBeforeParams, + DefaultAmountParams, + DefaultVendorDescriptionParams, + DelegateRoleParams, DelegateSubmitParams, + DelegatorParams, DeleteActionParams, DeleteConfirmationParams, - DeleteExpenseTranslationParams, DidSplitAmountMessageParams, - DistanceRateOperationsParams, EditActionParams, ElectronicFundsParams, - EnglishTranslation, EnterMagicCodeParams, + ExportAgainModalDescriptionParams, ExportedToIntegrationParams, + ExportIntegrationSelectedParams, + FeatureNameParams, + FiltersAmountBetweenParams, FormattedMaxLengthParams, ForwardedAmountParams, GoBackMessageParams, GoToRoomParams, + ImportedTagsMessageParams, + ImportFieldParams, + ImportMembersSuccessfullDescriptionParams, + ImportTagsSuccessfullDescriptionParams, + IncorrectZipFormatParams, InstantSummaryParams, + IntacctMappingTitleParams, + IntegrationExportParams, + IntegrationSyncFailedParams, + InvalidPropertyParams, + InvalidValueParams, IssueVirtualCardParams, + LastSyncAccountingParams, + LastSyncDateParams, LocalTimeParams, LoggedInAsParams, LogSizeParams, @@ -47,12 +87,15 @@ import type { ManagerApprovedParams, MarkedReimbursedParams, MarkReimbursedFromIntegrationParams, + MissingPropertyParams, NoLongerHaveAccessParams, NotAllowedExtensionParams, NotYouParams, OOOEventSummaryFullDayParams, OOOEventSummaryPartialDayParams, + OptionalParam, OurEmailProviderParams, + OwnerOwesAmountParams, PaidElsewhereWithAmountParams, PaidWithExpensifyWithAmountParams, ParentNavigationSummaryParams, @@ -62,21 +105,27 @@ import type { PayerPaidParams, PayerSettledParams, PaySomeoneParams, + ReconciliationWorksParams, ReimbursementRateParams, + RemovedFromApprovalWorkflowParams, RemovedTheRequestParams, + RemoveMemberPromptParams, RemoveMembersWarningPrompt, RenamedRoomActionParams, ReportArchiveReasonsClosedParams, ReportArchiveReasonsInvoiceReceiverPolicyDeletedParams, ReportArchiveReasonsMergedParams, - ReportArchiveReasonsPolicyDeletedParams, ReportArchiveReasonsRemovedFromPolicyParams, + ReportPolicyNameParams, RequestAmountParams, RequestCountParams, RequestedAmountMessageParams, + RequiredFieldParams, ResolutionConstraintsParams, + RoleNamesParams, RoomNameReservedErrorParams, RoomRenamedToParams, + SecondaryLoginParams, SetTheDistanceMerchantParams, SetTheRequestParams, SettledAfterAddedBankAccountParams, @@ -85,19 +134,32 @@ import type { SignUpNewFaceCodeParams, SizeExceededParams, SplitAmountParams, + SpreadCategoriesParams, + SpreadFieldNameParams, + SpreadSheetColumnParams, + StatementTitleParams, StepCounterParams, StripePaidParams, + SubscriptionCommitmentParams, + SubscriptionSettingsRenewsOnParams, + SubscriptionSettingsSaveUpToParams, + SubscriptionSizeParams, + SyncStageNameConnectionsParams, TaskCreatedActionParams, + TaxAmountParams, TermsParams, ThreadRequestReportNameParams, ThreadSentMoneyReportNameParams, ToValidateLoginParams, TransferParams, + TrialStartedTitleParams, UnapprovedParams, + UnapproveWithIntegrationWarningParams, UnshareParams, UntilTimeParams, UpdatedTheDistanceMerchantParams, UpdatedTheRequestParams, + UpdateRoleParams, UsePlusButtonParams, UserIsAlreadyMemberParams, UserSplitParams, @@ -122,11 +184,14 @@ import type { WelcomeNoteParams, WelcomeToRoomParams, WeSentYouMagicSignInLinkParams, + WorkspaceOwnerWillNeedToAddOrUpdatePaymentCardParams, + YourPlanPriceParams, ZipCodeExampleFormatParams, -} from './types'; +} from './params'; +import type {TranslationDeepObject} from './types'; /* eslint-disable max-len */ -export default { +const translations = { common: { cancel: 'Cancelar', dismiss: 'Descartar', @@ -254,7 +319,7 @@ export default { fieldRequired: 'Este campo es obligatorio.', requestModified: 'Esta solicitud está siendo modificada por otro miembro.', characterLimit: ({limit}: CharacterLimitParams) => `Supera el límite de ${limit} caracteres`, - characterLimitExceedCounter: ({length, limit}) => `Se superó el límite de caracteres (${length}/${limit})`, + characterLimitExceedCounter: ({length, limit}: CharacterLengthLimitParams) => `Se superó el límite de caracteres (${length}/${limit})`, dateInvalid: 'Por favor, selecciona una fecha válida.', invalidDateShouldBeFuture: 'Por favor, elige una fecha igual o posterior a hoy.', invalidTimeShouldBeFuture: 'Por favor, elige una hora al menos un minuto en el futuro.', @@ -636,7 +701,7 @@ export default { shouldUseYou ? `Este chat ya no está activo porque tu ya no eres miembro del espacio de trabajo ${policyName}.` : `Este chat está desactivado porque ${displayName} ha dejado de ser miembro del espacio de trabajo ${policyName}.`, - [CONST.REPORT.ARCHIVE_REASON.POLICY_DELETED]: ({policyName}: ReportArchiveReasonsPolicyDeletedParams) => + [CONST.REPORT.ARCHIVE_REASON.POLICY_DELETED]: ({policyName}: ReportArchiveReasonsInvoiceReceiverPolicyDeletedParams) => `Este chat está desactivado porque el espacio de trabajo ${policyName} se ha eliminado.`, [CONST.REPORT.ARCHIVE_REASON.INVOICE_RECEIVER_POLICY_DELETED]: ({policyName}: ReportArchiveReasonsInvoiceReceiverPolicyDeletedParams) => `Este chat está desactivado porque el espacio de trabajo ${policyName} se ha eliminado.`, @@ -677,14 +742,14 @@ export default { dragAndDrop: 'Arrastra y suelta un archivo de hoja de cálculo aquí', chooseSpreadsheet: 'Subir', fileContainsHeader: 'El archivo contiene encabezados', - column: (name: string) => `Columna ${name}`, - fieldNotMapped: (fieldName: string) => `¡Vaya! Un campo obligatorio ("${fieldName}") no ha sido mapeado. Por favor, revisa e inténtalo de nuevo.`, - singleFieldMultipleColumns: (fieldName: string) => `¡Vaya! Has mapeado un solo campo ("${fieldName}") a varias columnas. Por favor, revisa e inténtalo de nuevo.`, + column: ({name}: SpreadSheetColumnParams) => `Columna ${name}`, + fieldNotMapped: ({fieldName}: SpreadFieldNameParams) => `¡Vaya! Un campo obligatorio ("${fieldName}") no ha sido mapeado. Por favor, revisa e inténtalo de nuevo.`, + singleFieldMultipleColumns: ({fieldName}: SpreadFieldNameParams) => `¡Vaya! Has mapeado un solo campo ("${fieldName}") a varias columnas. Por favor, revisa e inténtalo de nuevo.`, importFailedTitle: 'Fallo en la importación', importFailedDescription: 'Por favor, asegúrate de que todos los campos estén llenos correctamente e inténtalo de nuevo. Si el problema persiste, por favor contacta a Concierge.', - importCategoriesSuccessfullDescription: (categories: number) => (categories > 1 ? `Se han agregado ${categories} categorías.` : 'Se ha agregado 1 categoría.'), - importMembersSuccessfullDescription: (members: number) => (members > 1 ? `Se han agregado ${members} miembros.` : 'Se ha agregado 1 miembro.'), - importTagsSuccessfullDescription: (tags: number) => (tags > 1 ? `Se han agregado ${tags} etiquetas.` : 'Se ha agregado 1 etiqueta.'), + importCategoriesSuccessfullDescription: ({categories}: SpreadCategoriesParams) => (categories > 1 ? `Se han agregado ${categories} categorías.` : 'Se ha agregado 1 categoría.'), + importMembersSuccessfullDescription: ({members}: ImportMembersSuccessfullDescriptionParams) => (members > 1 ? `Se han agregado ${members} miembros.` : 'Se ha agregado 1 miembro.'), + importTagsSuccessfullDescription: ({tags}: ImportTagsSuccessfullDescriptionParams) => (tags > 1 ? `Se han agregado ${tags} etiquetas.` : 'Se ha agregado 1 etiqueta.'), importSuccessfullTitle: 'Importar categorías', importDescription: 'Elige qué campos mapear desde tu hoja de cálculo haciendo clic en el menú desplegable junto a cada columna importada a continuación.', sizeNotMet: 'El archivo adjunto debe ser más grande que 0 bytes.', @@ -722,7 +787,7 @@ export default { splitBill: 'Dividir gasto', splitScan: 'Dividir recibo', splitDistance: 'Dividir distancia', - paySomeone: (name: string) => `Pagar a ${name ?? 'alguien'}`, + paySomeone: ({name}: PaySomeoneParams = {}) => `Pagar a ${name ?? 'alguien'}`, assignTask: 'Assignar tarea', header: 'Acción rápida', trackManual: 'Crear gasto', @@ -751,7 +816,7 @@ export default { share: 'Compartir', participants: 'Participantes', submitExpense: 'Presentar gasto', - paySomeone: ({name}: PaySomeoneParams) => `Pagar a ${name ?? 'alguien'}`, + paySomeone: ({name}: PaySomeoneParams = {}) => `Pagar a ${name ?? 'alguien'}`, trackExpense: 'Seguimiento de gastos', pay: 'Pagar', cancelPayment: 'Cancelar el pago', @@ -765,7 +830,10 @@ export default { 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: number) => `${count === 1 ? 'Problema encontrado' : 'Problemas encontrados'}`, + receiptIssuesFound: () => ({ + one: 'Problema encontrado', + other: 'Problemas encontrados', + }), fieldPending: 'Pendiente...', receiptScanning: 'Escaneando recibo...', receiptScanInProgress: 'Escaneado de recibo en proceso', @@ -785,19 +853,27 @@ export default { 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', @@ -812,6 +888,8 @@ export default { sendInvoice: ({amount}: RequestAmountParams) => `Enviar factura de ${amount}`, submitAmount: ({amount}: RequestAmountParams) => `solicitar ${amount}`, submittedAmount: ({formattedAmount, comment}: RequestedAmountMessageParams) => `solicitó ${formattedAmount}${comment ? ` para ${comment}` : ''}`, + automaticallySubmittedAmount: ({formattedAmount}: RequestedAmountMessageParams) => + `se enviaron automáticamente ${formattedAmount} mediante envío diferido`, trackedAmount: ({formattedAmount, comment}: RequestedAmountMessageParams) => `realizó un seguimiento de ${formattedAmount}${comment ? ` para ${comment}` : ''}`, splitAmount: ({amount}: SplitAmountParams) => `dividir ${amount}`, didSplitAmount: ({formattedAmount, comment}: DidSplitAmountMessageParams) => `dividió ${formattedAmount}${comment ? ` para ${comment}` : ''}`, @@ -820,7 +898,7 @@ export default { payerOwes: ({payer}: PayerOwesParams) => `${payer} debe: `, payerPaidAmount: ({payer, amount}: PayerPaidAmountParams) => `${payer ? `${payer} ` : ''}pagó ${amount}`, payerPaid: ({payer}: PayerPaidParams) => `${payer} pagó: `, - payerSpentAmount: ({payer, amount}: PayerPaidAmountParams): string => `${payer} gastó ${amount}`, + payerSpentAmount: ({payer, amount}: PayerPaidAmountParams) => `${payer} gastó ${amount}`, payerSpent: ({payer}: PayerPaidParams) => `${payer} gastó: `, managerApproved: ({manager}: ManagerApprovedParams) => `${manager} aprobó:`, managerApprovedAmount: ({manager, amount}: ManagerApprovedAmountParams) => `${manager} aprobó ${amount}`, @@ -895,20 +973,16 @@ export default { 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', @@ -928,7 +1002,7 @@ export default { unapprove: 'Desaprobar', unapproveReport: 'Anular la aprobación del informe', headsUp: 'Atención!', - unapproveWithIntegrationWarning: (accountingIntegration: string) => + unapproveWithIntegrationWarning: ({accountingIntegration}: UnapproveWithIntegrationWarningParams) => `Este informe ya se ha exportado a ${accountingIntegration}. Los cambios realizados en este informe en Expensify pueden provocar discrepancias en los datos y problemas de conciliación de la tarjeta Expensify. ¿Está seguro de que desea anular la aprobación de este informe?`, reimbursable: 'reembolsable', nonReimbursable: 'no reembolsable', @@ -1315,15 +1389,15 @@ export default { availableSpend: 'Límite restante', smartLimit: { name: 'Límite inteligente', - title: (formattedLimit: string) => `Puedes gastar hasta ${formattedLimit} en esta tarjeta al mes. El límite se restablecerá el primer día del mes.`, + title: ({formattedLimit}: ViolationsOverLimitParams) => `Puedes gastar hasta ${formattedLimit} en esta tarjeta al mes. El límite se restablecerá el primer día del mes.`, }, fixedLimit: { name: 'Límite fijo', - title: (formattedLimit: string) => `Puedes gastar hasta ${formattedLimit} en esta tarjeta, luego se desactivará.`, + title: ({formattedLimit}: ViolationsOverLimitParams) => `Puedes gastar hasta ${formattedLimit} en esta tarjeta, luego se desactivará.`, }, monthlyLimit: { name: 'Límite mensual', - title: (formattedLimit: string) => `Puedes gastar hasta ${formattedLimit} en esta tarjeta y el límite se restablecerá a medida que se aprueben tus gastos.`, + title: ({formattedLimit}: ViolationsOverLimitParams) => `Puedes gastar hasta ${formattedLimit} en esta tarjeta y el límite se restablecerá a medida que se aprueben tus gastos.`, }, virtualCardNumber: 'Número de la tarjeta virtual', physicalCardNumber: 'Número de la tarjeta física', @@ -1527,7 +1601,7 @@ export default { }, }, reportDetailsPage: { - inWorkspace: ({policyName}) => `en ${policyName}`, + inWorkspace: ({policyName}: ReportPolicyNameParams) => `en ${policyName}`, }, reportDescriptionPage: { roomDescription: 'Descripción de la sala de chat', @@ -1540,7 +1614,7 @@ export default { groupChat: { lastMemberTitle: '¡Atención!', lastMemberWarning: 'Ya que eres la última persona aquí, si te vas, este chat quedará inaccesible para todos los miembros. ¿Estás seguro de que quieres salir del chat?', - defaultReportName: ({displayName}: {displayName: string}) => `Chat de grupo de ${displayName}`, + defaultReportName: ({displayName}: ReportArchiveReasonsClosedParams) => `Chat de grupo de ${displayName}`, }, languagePage: { language: 'Idioma', @@ -1671,7 +1745,7 @@ export default { error: { dateShouldBeBefore: ({dateString}: DateShouldBeBeforeParams) => `La fecha debe ser anterior a ${dateString}.`, dateShouldBeAfter: ({dateString}: DateShouldBeAfterParams) => `La fecha debe ser posterior a ${dateString}.`, - incorrectZipFormat: (zipFormat?: string) => `Formato de código postal incorrecto.${zipFormat ? ` Formato aceptable: ${zipFormat}` : ''}`, + incorrectZipFormat: ({zipFormat}: IncorrectZipFormatParams = {}) => `Formato de código postal incorrecto.${zipFormat ? ` Formato aceptable: ${zipFormat}` : ''}`, hasInvalidCharacter: 'El nombre sólo puede incluir caracteres latinos.', }, }, @@ -2228,7 +2302,10 @@ export default { testTransactions: 'Transacciones de prueba', issueAndManageCards: 'Emitir y gestionar tarjetas', reconcileCards: 'Reconciliar tarjetas', - selected: ({selectedNumber}) => `${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', @@ -2247,7 +2324,7 @@ export default { `¡Has sido invitado a ${workspaceName}! Descargue la aplicación móvil Expensify en use.expensify.com/download para comenzar a rastrear sus gastos.`, subscription: 'Suscripción', markAsExported: 'Marcar como introducido manualmente', - exportIntegrationSelected: (connectionName: ConnectionName) => `Exportar a ${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[connectionName]}`, + exportIntegrationSelected: ({connectionName}: ExportIntegrationSelectedParams) => `Exportar a ${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[connectionName]}`, letsDoubleCheck: 'Verifiquemos que todo esté correcto', reportField: 'Campo del informe', lineItemLevel: 'Nivel de partida', @@ -2264,14 +2341,14 @@ export default { createNewConnection: 'Crear una nueva conexión', reuseExistingConnection: 'Reutilizar la conexión existente', existingConnections: 'Conexiones existentes', - lastSyncDate: (connectionName: string, formattedDate: string) => `${connectionName} - Última sincronización ${formattedDate}`, + lastSyncDate: ({connectionName, formattedDate}: LastSyncDateParams) => `${connectionName} - Última sincronización ${formattedDate}`, topLevel: 'Nivel superior', - authenticationError: (connectionName: string) => `No se puede conectar a ${connectionName} debido a un error de autenticación.`, + authenticationError: ({connectionName}: AuthenticationErrorParams) => `No se puede conectar a ${connectionName} debido a un error de autenticación.`, learnMore: 'Más información.', memberAlternateText: 'Los miembros pueden presentar y aprobar informes.', adminAlternateText: 'Los administradores tienen acceso total para editar todos los informes y la configuración del área de trabajo.', auditorAlternateText: 'Los auditores pueden ver y comentar los informes.', - roleName: (role?: string): string => { + roleName: ({role}: OptionalParam = {}) => { switch (role) { case CONST.POLICY.ROLE.ADMIN: return 'Administrador'; @@ -2402,8 +2479,8 @@ export default { accountsSwitchDescription: 'Las categorías activas estarán disponibles para ser escogidas cuando se crea un gasto.', trackingCategories: 'Categorías de seguimiento', trackingCategoriesDescription: 'Elige cómo gestionar categorías de seguimiento de Xero en Expensify.', - mapTrackingCategoryTo: ({categoryName}) => `Asignar ${categoryName} de Xero a`, - mapTrackingCategoryToDescription: ({categoryName}) => `Elige dónde mapear ${categoryName} al exportar a Xero.`, + mapTrackingCategoryTo: ({categoryName}: CategoryNameParams) => `Asignar ${categoryName} de Xero a`, + mapTrackingCategoryToDescription: ({categoryName}: CategoryNameParams) => `Elige dónde mapear ${categoryName} al exportar a Xero.`, customers: 'Volver a facturar a los clientes', customersDescription: 'Elige si quieres volver a facturar a los clientes en Expensify. Tus contactos de clientes de Xero se pueden etiquetar como gastos, y se exportarán a Xero como una factura de venta.', @@ -2504,7 +2581,7 @@ export default { }, creditCardAccount: 'Cuenta de tarjeta de crédito', defaultVendor: 'Proveedor por defecto', - defaultVendorDescription: (isReimbursable: boolean): string => + defaultVendorDescription: ({isReimbursable}: DefaultVendorDescriptionParams) => `Establezca un proveedor predeterminado que se aplicará a los gastos ${isReimbursable ? '' : 'no '}reembolsables que no tienen un proveedor coincidente en Sage Intacct.`, exportDescription: 'Configure cómo se exportan los datos de Expensify a Sage Intacct.', exportPreferredExporterNote: @@ -2722,12 +2799,12 @@ export default { importJobs: 'Importar proyectos', customers: 'clientes', jobs: 'proyectos', - label: (importFields: string[], importType: string) => `${importFields.join(' y ')}, ${importType}`, + label: ({importFields, importType}: CustomersOrJobsLabelParams) => `${importFields.join(' y ')}, ${importType}`, }, importTaxDescription: 'Importar grupos de impuestos desde NetSuite.', importCustomFields: { chooseOptionBelow: 'Elija una de las opciones siguientes:', - requiredFieldError: (fieldName: string) => `Por favor, introduzca el ${fieldName}`, + requiredFieldError: ({fieldName}: RequiredFieldParams) => `Por favor, introduzca el ${fieldName}`, customSegments: { title: 'Segmentos/registros personalizados', addText: 'Añadir segmento/registro personalizado', @@ -2768,7 +2845,7 @@ export default { customRecordMappingTitle: '¿Cómo debería mostrarse este registro de segmento personalizado en Expensify?', }, errors: { - uniqueFieldError: (fieldName: string) => `Ya existe un segmento/registro personalizado con este ${fieldName?.toLowerCase()}.`, + uniqueFieldError: ({fieldName}: RequiredFieldParams) => `Ya existe un segmento/registro personalizado con este ${fieldName?.toLowerCase()}.`, }, }, customLists: { @@ -2802,18 +2879,18 @@ export default { [CONST.INTEGRATION_ENTITY_MAP_TYPES.NETSUITE_DEFAULT]: { label: 'Predeterminado del empleado NetSuite', description: 'No importado a Expensify, aplicado en exportación', - footerContent: (importField: string) => + footerContent: ({importField}: ImportFieldParams) => `Si usa ${importField} en NetSuite, aplicaremos el conjunto predeterminado en el registro del empleado al exportarlo a Informe de gastos o Entrada de diario.`, }, [CONST.INTEGRATION_ENTITY_MAP_TYPES.TAG]: { label: 'Etiquetas', description: 'Nivel de línea de pedido', - footerContent: (importField: string) => `Se podrán seleccionar ${importField} para cada gasto individual en el informe de un empleado.`, + footerContent: ({importField}: ImportFieldParams) => `Se podrán seleccionar ${importField} para cada gasto individual en el informe de un empleado.`, }, [CONST.INTEGRATION_ENTITY_MAP_TYPES.REPORT_FIELD]: { label: 'Campos de informe', description: 'Nivel de informe', - footerContent: (importField: string) => `La selección de ${importField} se aplicará a todos los gastos en el informe de un empleado.`, + footerContent: ({importField}: ImportFieldParams) => `La selección de ${importField} se aplicará a todos los gastos en el informe de un empleado.`, }, }, }, @@ -2844,8 +2921,11 @@ export default { 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: number) => `${dimensionsCount} ${Str.pluralize('UDD', `UDDs`, dimensionsCount)} añadido`, - mappingTitle: (mappingName: SageIntacctMappingName): string => { + 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: return 'departamentos'; @@ -2879,7 +2959,7 @@ export default { }, yourCardProvider: `¿Quién es su proveedor de tarjetas?`, enableFeed: { - title: (provider: string) => `Habilita tu feed ${provider}`, + title: ({provider}: GoBackMessageParams) => `Habilita tu feed ${provider}`, heading: 'Tenemos una integración directa con el emisor de su tarjeta y podemos importar los datos de sus transacciones a Expensify de forma rápida y precisa.\n\nPara empezar, simplemente:', visa: `1. Visite [este artículo de ayuda](${CONST.COMPANY_CARDS_HELP}) para obtener instrucciones detalladas sobre cómo configurar sus tarjetas comerciales Visa.\n\n2. [Póngase en contacto con su banco](${CONST.COMPANY_CARDS_HELP}) para comprobar que admiten un feed personalizado para su programa, y pídales que lo activen.\n\n3. *Una vez que el feed esté habilitado y tengas sus datos, pasa a la siguiente pantalla.*`, @@ -2927,7 +3007,7 @@ export default { card: 'Tarjeta', startTransactionDate: 'Fecha de inicio de transacciones', cardName: 'Nombre de la tarjeta', - assignedYouCard: (assigner: string) => `¡${assigner} te ha asignado una tarjeta de empresa! Las transacciones importadas aparecerán en este chat.`, + assignedYouCard: ({assigner}: AssignedYouCardParams) => `¡${assigner} te ha asignado una tarjeta de empresa! Las transacciones importadas aparecerán en este chat.`, chooseCardFeed: 'Elige feed de tarjetas', }, expensifyCard: { @@ -2975,21 +3055,21 @@ export default { deactivate: 'Desactivar tarjeta', changeCardLimit: 'Modificar el límite de la tarjeta', changeLimit: 'Modificar límite', - smartLimitWarning: (limit: string) => + smartLimitWarning: ({limit}: CharacterLimitParams) => `Si cambias el límite de esta tarjeta a ${limit}, las nuevas transacciones serán rechazadas hasta que apruebes antiguos gastos de la tarjeta.`, - monthlyLimitWarning: (limit: string) => `Si cambias el límite de esta tarjeta a ${limit}, las nuevas transacciones serán rechazadas hasta el próximo mes.`, - fixedLimitWarning: (limit: string) => `Si cambias el límite de esta tarjeta a ${limit}, se rechazarán las nuevas transacciones.`, + monthlyLimitWarning: ({limit}: CharacterLimitParams) => `Si cambias el límite de esta tarjeta a ${limit}, las nuevas transacciones serán rechazadas hasta el próximo mes.`, + fixedLimitWarning: ({limit}: CharacterLimitParams) => `Si cambias el límite de esta tarjeta a ${limit}, se rechazarán las nuevas transacciones.`, changeCardLimitType: 'Modificar el tipo de límite de la tarjeta', changeLimitType: 'Modificar el tipo de límite', - changeCardSmartLimitTypeWarning: (limit: string) => + changeCardSmartLimitTypeWarning: ({limit}: CharacterLimitParams) => `Si cambias el tipo de límite de esta tarjeta a Límite inteligente, las nuevas transacciones serán rechazadas porque ya se ha alcanzado el límite de ${limit} no aprobado.`, - changeCardMonthlyLimitTypeWarning: (limit: string) => + changeCardMonthlyLimitTypeWarning: ({limit}: CharacterLimitParams) => `Si cambias el tipo de límite de esta tarjeta a Mensual, las nuevas transacciones serán rechazadas porque ya se ha alcanzado el límite de ${limit} mensual.`, addShippingDetails: 'Añadir detalles de envío', - issuedCard: (assignee: string) => `¡emitió a ${assignee} una Tarjeta Expensify! La tarjeta llegará en 2-3 días laborables.`, - issuedCardNoShippingDetails: (assignee: string) => `¡emitió a ${assignee} una Tarjeta Expensify! La tarjeta se enviará una vez que se agreguen los detalles de envío.`, + issuedCard: ({assignee}: AssigneeParams) => `¡emitió a ${assignee} una Tarjeta Expensify! La tarjeta llegará en 2-3 días laborables.`, + issuedCardNoShippingDetails: ({assignee}: AssigneeParams) => `¡emitió a ${assignee} una Tarjeta Expensify! La tarjeta se enviará una vez que se agreguen los detalles de envío.`, issuedCardVirtual: ({assignee, link}: IssueVirtualCardParams) => `¡emitió a ${assignee} una ${link} virtual! La tarjeta puede utilizarse inmediatamente.`, - addedShippingDetails: (assignee: string) => `${assignee} agregó los detalles de envío. La Tarjeta Expensify llegará en 2-3 días hábiles.`, + addedShippingDetails: ({assignee}: AssigneeParams) => `${assignee} agregó los detalles de envío. La Tarjeta Expensify llegará en 2-3 días hábiles.`, }, categories: { deleteCategories: 'Eliminar categorías', @@ -3088,8 +3168,9 @@ export default { cardNumber: 'Número de la tarjeta', cardholder: 'Titular de la tarjeta', cardName: 'Nombre de la tarjeta', - integrationExport: (integration: string, type: string) => `Exportación a ${integration} ${type}`, - integrationExportTitleFirstPart: (integration: string) => `Seleccione la cuenta ${integration} donde se deben exportar las transacciones. Seleccione una cuenta diferente`, + integrationExport: ({integration, type}: IntegrationExportParams) => `Exportación a ${integration} ${type}`, + integrationExportTitleFirstPart: ({integration}: IntegrationExportParams) => + `Seleccione la cuenta ${integration} donde se deben exportar las transacciones. Seleccione una cuenta diferente`, integrationExportTitleLinkPart: 'opción de exportación', integrationExportTitleSecondPart: 'para cambiar las cuentas disponibles.', lastUpdated: 'Última actualización', @@ -3123,7 +3204,7 @@ export default { giveItNameInstruction: 'Nombra la tarjeta para distingirla de las demás.', updating: 'Actualizando...', noAccountsFound: 'No se han encontrado cuentas', - noAccountsFoundDescription: (connection: string) => `Añade la cuenta en ${connection} y sincroniza la conexión de nuevo.`, + noAccountsFoundDescription: ({connection}: ConnectionParams) => `Añade la cuenta en ${connection} y sincroniza la conexión de nuevo.`, }, workflows: { title: 'Flujos de trabajo', @@ -3244,7 +3325,7 @@ export default { tagRules: 'Reglas de etiquetas', approverDescription: 'Aprobador', importTags: 'Importar categorías', - importedTagsMessage: (columnCounts: number) => + importedTagsMessage: ({columnCounts}: ImportedTagsMessageParams) => `Hemos encontrado *${columnCounts} columnas* en su hoja de cálculo. Seleccione *Nombre* junto a la columna que contiene los nombres de las etiquetas. También puede seleccionar *Habilitado* junto a la columna que establece el estado de la etiqueta.`, }, taxes: { @@ -3267,7 +3348,7 @@ export default { updateTaxClaimableFailureMessage: 'La porción recuperable debe ser menor al monto del importe por distancia.', }, deleteTaxConfirmation: '¿Estás seguro de que quieres eliminar este impuesto?', - deleteMultipleTaxConfirmation: ({taxAmount}) => `¿Estás seguro de que quieres eliminar ${taxAmount} impuestos?`, + deleteMultipleTaxConfirmation: ({taxAmount}: TaxAmountParams) => `¿Estás seguro de que quieres eliminar ${taxAmount} impuestos?`, actions: { delete: 'Eliminar tasa', deleteMultiple: 'Eliminar tasas', @@ -3310,7 +3391,7 @@ export default { removeWorkspaceMemberButtonTitle: 'Eliminar del espacio de trabajo', removeGroupMemberButtonTitle: 'Eliminar del grupo', removeRoomMemberButtonTitle: 'Eliminar del chat', - removeMemberPrompt: ({memberName}: {memberName: string}) => `¿Estás seguro de que deseas eliminar a ${memberName}?`, + removeMemberPrompt: ({memberName}: RemoveMemberPromptParams) => `¿Estás seguro de que deseas eliminar a ${memberName}?`, removeMemberTitle: 'Eliminar miembro', transferOwner: 'Transferir la propiedad', makeMember: 'Hacer miembro', @@ -3323,7 +3404,7 @@ export default { genericRemove: 'Ha ocurrido un problema al eliminar al miembro del espacio de trabajo.', }, addedWithPrimary: 'Se agregaron algunos miembros con sus nombres de usuario principales.', - invitedBySecondaryLogin: ({secondaryLogin}) => `Agregado por nombre de usuario secundario ${secondaryLogin}.`, + invitedBySecondaryLogin: ({secondaryLogin}: SecondaryLoginParams) => `Agregado por nombre de usuario secundario ${secondaryLogin}.`, membersListTitle: 'Directorio de todos los miembros del espacio de trabajo.', importMembers: 'Importar miembros', }, @@ -3335,8 +3416,8 @@ export default { xero: 'Xero', netsuite: 'NetSuite', intacct: 'Sage Intacct', - connectionName: (integration: ConnectionName) => { - switch (integration) { + connectionName: ({connectionName}: ConnectionNameParams) => { + switch (connectionName) { case CONST.POLICY.CONNECTIONS.NAME.QBO: return 'Quickbooks Online'; case CONST.POLICY.CONNECTIONS.NAME.XERO: @@ -3353,20 +3434,21 @@ export default { errorODIntegration: 'Hay un error con una conexión que se ha configurado en Expensify Classic. ', goToODToFix: 'Ve a Expensify Classic para solucionar este problema.', setup: 'Configurar', - lastSync: (relativeDate: string) => `Recién sincronizado ${relativeDate}`, + lastSync: ({relativeDate}: LastSyncAccountingParams) => `Recién sincronizado ${relativeDate}`, import: 'Importar', export: 'Exportar', advanced: 'Avanzado', other: 'Otras integraciones', syncNow: 'Sincronizar ahora', disconnect: 'Desconectar', - disconnectTitle: (integration?: ConnectionName): string => { - const integrationName = integration && CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[integration] ? CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[integration] : 'integración'; + disconnectTitle: ({connectionName}: OptionalParam = {}) => { + const integrationName = + connectionName && CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[connectionName] ? CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[connectionName] : 'integración'; return `Desconectar ${integrationName}`; }, - connectTitle: (integrationToConnect: ConnectionName): string => `Conectar ${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[integrationToConnect] ?? 'accounting integration'}`, - syncError: (integration?: ConnectionName): string => { - switch (integration) { + connectTitle: ({connectionName}: ConnectionNameParams) => `Conectar ${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[connectionName] ?? 'accounting integration'}`, + syncError: ({connectionName}: OptionalParam = {}) => { + switch (connectionName) { case CONST.POLICY.CONNECTIONS.NAME.QBO: return 'No se puede conectar a QuickBooks Online.'; case CONST.POLICY.CONNECTIONS.NAME.XERO: @@ -3392,18 +3474,18 @@ export default { [CONST.INTEGRATION_ENTITY_MAP_TYPES.REPORT_FIELD]: 'Importado como campos de informe', [CONST.INTEGRATION_ENTITY_MAP_TYPES.NETSUITE_DEFAULT]: 'Predeterminado del empleado NetSuite', }, - disconnectPrompt: (currentIntegration?: ConnectionName): string => { + disconnectPrompt: ({connectionName}: OptionalParam = {}) => { const integrationName = - currentIntegration && CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[currentIntegration] ? CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[currentIntegration] : 'integración'; + connectionName && CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[connectionName] ? CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[connectionName] : 'integración'; return `¿Estás seguro de que quieres desconectar ${integrationName}?`; }, - connectPrompt: (integrationToConnect: ConnectionName): string => + connectPrompt: ({connectionName}: ConnectionNameParams) => `¿Estás seguro de que quieres conectar a ${ - CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[integrationToConnect] ?? 'esta integración contable' + CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[connectionName] ?? 'esta integración contable' }? Esto eliminará cualquier conexión contable existente.`, enterCredentials: 'Ingresa tus credenciales', connections: { - syncStageName: (stage: PolicyConnectionSyncStage) => { + syncStageName: ({stage}: SyncStageNameConnectionsParams) => { switch (stage) { case 'quickbooksOnlineImportCustomers': return 'Importando clientes'; @@ -3540,7 +3622,7 @@ export default { chooseBankAccount: 'Elige la cuenta bancaria con la que se conciliarán los pagos de tu Tarjeta Expensify.', accountMatches: 'Asegúrate de que esta cuenta coincide con ', settlementAccount: 'la cuenta de liquidación de tu Tarjeta Expensify ', - reconciliationWorks: (lastFourPAN: string) => `(que termina en ${lastFourPAN}) para que la conciliación continua funcione correctamente.`, + reconciliationWorks: ({lastFourPAN}: ReconciliationWorksParams) => `(que termina en ${lastFourPAN}) para que la conciliación continua funcione correctamente.`, }, }, card: { @@ -3638,9 +3720,18 @@ export default { rate: 'Tasa', addRate: 'Agregar tasa', trackTax: 'Impuesto de seguimiento', - deleteRates: ({count}: DistanceRateOperationsParams) => `Eliminar ${Str.pluralize('tasa', 'tasas', count)}`, - enableRates: ({count}: DistanceRateOperationsParams) => `Activar ${Str.pluralize('tasa', 'tasas', count)}`, - disableRates: ({count}: DistanceRateOperationsParams) => `Desactivar ${Str.pluralize('tasa', 'tasas', count)}`, + deleteRates: () => ({ + one: 'Eliminar tasa', + other: 'Eliminar tasas', + }), + enableRates: () => ({ + one: 'Activar tasa', + other: 'Activar tasas', + }), + disableRates: () => ({ + one: 'Desactivar tasa', + other: 'Desactivar tasas', + }), enableRate: 'Activar tasa', status: 'Estado', unit: 'Unidad', @@ -3648,7 +3739,10 @@ export default { 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', @@ -3708,19 +3802,19 @@ export default { amountOwedText: 'Esta cuenta tiene un saldo pendiente de un mes anterior.\n\n¿Quiere liquidar el saldo y hacerse cargo de la facturación de este espacio de trabajo?', ownerOwesAmountTitle: 'Saldo pendiente', ownerOwesAmountButtonText: 'Transferir saldo', - ownerOwesAmountText: ({email, amount}) => + ownerOwesAmountText: ({email, amount}: OwnerOwesAmountParams) => `La cuenta propietaria de este espacio de trabajo (${email}) tiene un saldo pendiente de un mes anterior.\n\n¿Desea transferir este monto (${amount}) para hacerse cargo de la facturación de este espacio de trabajo? tu tarjeta de pago se cargará inmediatamente.`, subscriptionTitle: 'Asumir la suscripción anual', subscriptionButtonText: 'Transferir suscripción', - subscriptionText: ({usersCount, finalCount}) => + subscriptionText: ({usersCount, finalCount}: ChangeOwnerSubscriptionParams) => `Al hacerse cargo de este espacio de trabajo se fusionará tu suscripción anual asociada con tu suscripción actual. Esto aumentará el tamaño de tu suscripción en ${usersCount} miembros, lo que hará que tu nuevo tamaño de suscripción sea ${finalCount}. ¿Te gustaria continuar?`, duplicateSubscriptionTitle: 'Alerta de suscripción duplicada', duplicateSubscriptionButtonText: 'Continuar', - duplicateSubscriptionText: ({email, workspaceName}) => + duplicateSubscriptionText: ({email, workspaceName}: ChangeOwnerDuplicateSubscriptionParams) => `Parece que estás intentando hacerte cargo de la facturación de los espacios de trabajo de ${email}, pero para hacerlo, primero debes ser administrador de todos sus espacios de trabajo.\n\nHaz clic en "Continuar" si solo quieres tomar sobrefacturación para el espacio de trabajo ${workspaceName}.\n\nSi desea hacerse cargo de la facturación de toda tu suscripción, pídales que lo agreguen como administrador a todos sus espacios de trabajo antes de hacerse cargo de la facturación.`, hasFailedSettlementsTitle: 'No se puede transferir la propiedad', hasFailedSettlementsButtonText: 'Entiendo', - hasFailedSettlementsText: ({email}) => + hasFailedSettlementsText: ({email}: ChangeOwnerHasFailedSettlementsParams) => `No puede hacerse cargo de la facturación porque ${email} tiene una liquidación vencida de la tarjeta Expensify. Avíseles que se comuniquen con concierge@expensify.com para resolver el problema. Luego, podrá hacerse cargo de la facturación de este espacio de trabajo.`, failedToClearBalanceTitle: 'Fallo al liquidar el saldo', failedToClearBalanceButtonText: 'OK', @@ -3735,7 +3829,7 @@ export default { exportAgainModal: { title: '¡Cuidado!', - description: (reportName: string, connectionName: ConnectionName) => + description: ({reportName, connectionName}: ExportAgainModalDescriptionParams) => `Los siguientes informes ya se han exportado a ${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[connectionName]}:\n\n${reportName}\n\n¿Estás seguro de que deseas exportarlos de nuevo?`, confirmText: 'Sí, exportar de nuevo', cancelText: 'Cancelar', @@ -3798,7 +3892,7 @@ export default { upgradeToUnlock: 'Desbloquear esta función', completed: { headline: 'Has mejorado tu espacio de trabajo.', - successMessage: (policyName: string) => `Ha mejorado correctamente su espacio de trabajo ${policyName} al plan Control.`, + successMessage: ({policyName}: ReportPolicyNameParams) => `Ha mejorado correctamente su espacio de trabajo ${policyName} al plan Control.`, viewSubscription: 'Ver su suscripción', moreDetails: 'para obtener más información.', gotIt: 'Entendido, gracias.', @@ -3806,8 +3900,8 @@ export default { }, restrictedAction: { restricted: 'Restringido', - actionsAreCurrentlyRestricted: ({workspaceName}) => `Las acciones en el espacio de trabajo ${workspaceName} están actualmente restringidas`, - workspaceOwnerWillNeedToAddOrUpdatePaymentCard: ({workspaceOwnerName}) => + actionsAreCurrentlyRestricted: ({workspaceName}: ActionsAreCurrentlyRestricted) => `Las acciones en el espacio de trabajo ${workspaceName} están actualmente restringidas`, + workspaceOwnerWillNeedToAddOrUpdatePaymentCard: ({workspaceOwnerName}: WorkspaceOwnerWillNeedToAddOrUpdatePaymentCardParams) => `El propietario del espacio de trabajo, ${workspaceOwnerName} tendrá que añadir o actualizar la tarjeta de pago registrada para desbloquear nueva actividad en el espacio de trabajo.`, youWillNeedToAddOrUpdatePaymentCard: 'Debes añadir o actualizar la tarjeta de pago registrada para desbloquear nueva actividad en el espacio de trabajo.', addPaymentCardToUnlock: 'Añade una tarjeta para desbloquearlo!', @@ -3828,7 +3922,10 @@ export default { 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: number) => `${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', @@ -3865,26 +3962,26 @@ export default { randomReportAuditDescription: 'Requiere que algunos informes sean aprobados manualmente, incluso si son elegibles para la aprobación automática.', autoPayApprovedReportsTitle: 'Pago automático de informes aprobados', autoPayApprovedReportsSubtitle: 'Configura qué informes de gastos pueden pagarse de forma automática.', - autoPayApprovedReportsLimitError: (currency?: string) => `Por favor, introduce un monto menor a ${currency ?? ''}20,000`, + autoPayApprovedReportsLimitError: ({currency}: AutoPayApprovedReportsLimitErrorParams = {}) => `Por favor, introduce un monto menor a ${currency ?? ''}20,000`, autoPayApprovedReportsLockedSubtitle: 'Ve a más funciones y habilita flujos de trabajo, luego agrega pagos para desbloquear esta función.', autoPayReportsUnderTitle: 'Pagar automáticamente informes por debajo de', autoPayReportsUnderDescription: 'Los informes de gastos totalmente conformes por debajo de esta cantidad se pagarán automáticamente.', unlockFeatureGoToSubtitle: 'Ir a', - unlockFeatureEnableWorkflowsSubtitle: (featureName: string) => `y habilita flujos de trabajo, luego agrega ${featureName} para desbloquear esta función.`, - enableFeatureSubtitle: (featureName: string) => `y habilita ${featureName} para desbloquear esta función.`, + unlockFeatureEnableWorkflowsSubtitle: ({featureName}: FeatureNameParams) => `y habilita flujos de trabajo, luego agrega ${featureName} para desbloquear esta función.`, + enableFeatureSubtitle: ({featureName}: FeatureNameParams) => `y habilita ${featureName} para desbloquear esta función.`, }, categoryRules: { title: 'Reglas de categoría', approver: 'Aprobador', requireDescription: 'Requerir descripción', descriptionHint: 'Sugerencia de descripción', - descriptionHintDescription: (categoryName: string) => + descriptionHintDescription: ({categoryName}: CategoryNameParams) => `Recuerda a los empleados que deben proporcionar información adicional para los gastos de “${categoryName}”. Esta sugerencia aparece en el campo de descripción en los gastos.`, descriptionHintLabel: 'Sugerencia', descriptionHintSubtitle: 'Consejo: ¡Cuanto más corta, mejor!', maxAmount: 'Importe máximo', flagAmountsOver: 'Señala importes superiores a', - flagAmountsOverDescription: (categoryName: string) => `Aplica a la categoría “${categoryName}”.`, + flagAmountsOverDescription: ({categoryName}: CategoryNameParams) => `Aplica a la categoría “${categoryName}”.`, flagAmountsOverSubtitle: 'Esto anula el importe máximo para todos los gastos.', expenseLimitTypes: { expense: 'Gasto individual', @@ -3894,7 +3991,7 @@ export default { }, requireReceiptsOver: 'Requerir recibos para importes superiores a', requireReceiptsOverList: { - default: (defaultAmount: string) => `${defaultAmount} ${CONST.DOT_SEPARATOR} Predeterminado`, + default: ({defaultAmount}: DefaultAmountParams) => `${defaultAmount} ${CONST.DOT_SEPARATOR} Predeterminado`, never: 'Nunca requerir recibos', always: 'Requerir recibos siempre', }, @@ -3958,8 +4055,8 @@ export default { }, }, workspaceActions: { - renamedWorkspaceNameAction: ({oldName, newName}) => `actualizó el nombre de este espacio de trabajo de ${oldName} a ${newName}`, - removedFromApprovalWorkflow: ({submittersNames}: {submittersNames: string[]}) => { + renamedWorkspaceNameAction: ({oldName, newName}: RenamedRoomActionParams) => `actualizó el nombre de este espacio de trabajo de ${oldName} a ${newName}`, + removedFromApprovalWorkflow: ({submittersNames}: RemovedFromApprovalWorkflowParams) => { let joinedNames = ''; if (submittersNames.length === 1) { joinedNames = submittersNames[0]; @@ -3968,9 +4065,10 @@ export default { } 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: { @@ -4012,7 +4110,7 @@ export default { deleteConfirmation: '¿Estás seguro de que quieres eliminar esta tarea?', }, statementPage: { - title: (year, monthName) => `Estado de cuenta de ${monthName} ${year}`, + title: ({year, monthName}: StatementTitleParams) => `Estado de cuenta de ${monthName} ${year}`, generatingPDF: 'Estamos generando tu PDF ahora mismo. ¡Por favor, vuelve más tarde!', }, keyboardShortcutsPage: { @@ -4062,8 +4160,8 @@ export default { filtersHeader: 'Filtros', filters: { date: { - before: (date?: string) => `Antes de ${date ?? ''}`, - after: (date?: string) => `Después de ${date ?? ''}`, + before: ({date}: OptionalParam = {}) => `Antes de ${date ?? ''}`, + after: ({date}: OptionalParam = {}) => `Después de ${date ?? ''}`, }, status: 'Estado', keyword: 'Palabra clave', @@ -4073,9 +4171,9 @@ export default { pinned: 'Fijado', unread: 'No leído', amount: { - lessThan: (amount?: string) => `Menos de ${amount ?? ''}`, - greaterThan: (amount?: string) => `Más que ${amount ?? ''}`, - between: (greaterThan: string, lessThan: string) => `Entre ${greaterThan} y ${lessThan}`, + lessThan: ({amount}: OptionalParam = {}) => `Menos de ${amount ?? ''}`, + greaterThan: ({amount}: OptionalParam = {}) => `Más que ${amount ?? ''}`, + between: ({greaterThan, lessThan}: FiltersAmountBetweenParams) => `Entre ${greaterThan} y ${lessThan}`, }, current: 'Actual', past: 'Anterior', @@ -4196,7 +4294,7 @@ export default { nonReimbursableLink: 'Ver los gastos de la tarjeta de empresa.', pending: ({label}: ExportedToIntegrationParams) => `comenzó a exportar este informe a ${label}...`, }, - integrationsMessage: (errorMessage: string, label: string) => `no se pudo exportar este informe a ${label} ("${errorMessage}").`, + integrationsMessage: ({label, errorMessage}: IntegrationSyncFailedParams) => `no se pudo exportar este informe a ${label} ("${errorMessage}").`, managerAttachReceipt: `agregó un recibo`, managerDetachReceipt: `quitó un recibo`, markedReimbursed: ({amount, currency}: MarkedReimbursedParams) => `pagó ${currency}${amount} en otro lugar`, @@ -4213,11 +4311,11 @@ export default { stripePaid: ({amount, currency}: StripePaidParams) => `pagado ${currency}${amount}`, takeControl: `tomó el control`, unapproved: ({amount, currency}: UnapprovedParams) => `no aprobado ${currency}${amount}`, - integrationSyncFailed: (label: string, errorMessage: string) => `no se pudo sincronizar con ${label} ("${errorMessage}")`, - addEmployee: (email: string, role: string) => `agregó a ${email} como ${role === 'user' ? 'miembro' : 'administrador'}`, - updateRole: (email: string, currentRole: string, newRole: string) => + integrationSyncFailed: ({label, errorMessage}: IntegrationSyncFailedParams) => `no se pudo sincronizar con ${label} ("${errorMessage}")`, + addEmployee: ({email, role}: AddEmployeeParams) => `agregó a ${email} como ${role === 'user' ? 'miembro' : 'administrador'}`, + updateRole: ({email, currentRole, newRole}: UpdateRoleParams) => `actualicé el rol ${email} de ${currentRole === 'user' ? 'miembro' : 'administrador'} a ${newRole === 'user' ? 'miembro' : 'administrador'}`, - removeMember: (email: string, role: string) => `eliminado ${role === 'user' ? 'miembro' : 'administrador'} ${email}`, + removeMember: ({email, role}: AddEmployeeParams) => `eliminado ${role === 'user' ? 'miembro' : 'administrador'} ${email}`, }, }, }, @@ -4898,9 +4996,9 @@ export default { allTagLevelsRequired: 'Todas las etiquetas son obligatorias', autoReportedRejectedExpense: ({rejectedBy, rejectReason}: ViolationsAutoReportedRejectedExpenseParams) => `${rejectedBy} rechazó la solicitud y comentó "${rejectReason}"`, billableExpense: 'La opción facturable ya no es válida', - cashExpenseWithNoReceipt: ({formattedLimit}: ViolationsCashExpenseWithNoReceiptParams) => `Recibo obligatorio para cantidades mayores de ${formattedLimit}`, + cashExpenseWithNoReceipt: ({formattedLimit}: ViolationsCashExpenseWithNoReceiptParams = {}) => `Recibo obligatorio para cantidades mayores de ${formattedLimit}`, categoryOutOfPolicy: 'La categoría ya no es válida', - conversionSurcharge: ({surcharge}: ViolationsConversionSurchargeParams = {}) => `${surcharge}% de recargo aplicado`, + conversionSurcharge: ({surcharge}: ViolationsConversionSurchargeParams) => `${surcharge}% de recargo aplicado`, customUnitOutOfPolicy: 'Tasa inválida para este espacio de trabajo', duplicatedTransaction: 'Duplicado', fieldRequired: 'Los campos del informe son obligatorios', @@ -4909,7 +5007,7 @@ export default { maxAge: ({maxAge}: ViolationsMaxAgeParams) => `Fecha de más de ${maxAge} días`, missingCategory: 'Falta categoría', missingComment: 'Descripción obligatoria para la categoría seleccionada', - missingTag: ({tagName}: ViolationsMissingTagParams) => `Falta ${tagName ?? 'etiqueta'}`, + missingTag: ({tagName}: ViolationsMissingTagParams = {}) => `Falta ${tagName ?? 'etiqueta'}`, modifiedAmount: ({type, displayPercentVariance}: ViolationsModifiedAmountParams) => { switch (type) { case 'distance': @@ -4960,10 +5058,10 @@ export default { return ''; }, smartscanFailed: 'No se pudo escanear el recibo. Introduce los datos manualmente', - someTagLevelsRequired: ({tagName}: ViolationsTagOutOfPolicyParams) => `Falta ${tagName ?? 'Tag'}`, - tagOutOfPolicy: ({tagName}: ViolationsTagOutOfPolicyParams) => `La etiqueta ${tagName ? `${tagName} ` : ''}ya no es válida`, + someTagLevelsRequired: ({tagName}: ViolationsTagOutOfPolicyParams = {}) => `Falta ${tagName ?? 'Tag'}`, + tagOutOfPolicy: ({tagName}: ViolationsTagOutOfPolicyParams = {}) => `La etiqueta ${tagName ? `${tagName} ` : ''}ya no es válida`, taxAmountChanged: 'El importe del impuesto fue modificado', - taxOutOfPolicy: ({taxName}: ViolationsTaxOutOfPolicyParams) => `${taxName ?? 'El impuesto'} ya no es válido`, + taxOutOfPolicy: ({taxName}: ViolationsTaxOutOfPolicyParams = {}) => `${taxName ?? 'El impuesto'} ya no es válido`, taxRateChanged: 'La tasa de impuesto fue modificada', taxRequired: 'Falta la tasa de impuesto', none: 'Ninguno', @@ -4980,7 +5078,7 @@ export default { hold: 'Bloqueado', }, reportViolations: { - [CONST.REPORT_VIOLATIONS.FIELD_REQUIRED]: (fieldName: string) => `${fieldName} es obligatorio`, + [CONST.REPORT_VIOLATIONS.FIELD_REQUIRED]: ({fieldName}: RequiredFieldParams) => `${fieldName} es obligatorio`, }, violationDismissal: { rter: { @@ -5035,12 +5133,12 @@ export default { authenticatePaymentCard: 'Autenticar tarjeta de pago', mobileReducedFunctionalityMessage: 'No puedes hacer cambios en tu suscripción en la aplicación móvil.', badge: { - freeTrial: ({numOfDays}) => `Prueba gratuita: ${numOfDays === 1 ? `queda 1 día` : `quedan ${numOfDays} días`}`, + freeTrial: ({numOfDays}: BadgeFreeTrialParams) => `Prueba gratuita: ${numOfDays === 1 ? `queda 1 día` : `quedan ${numOfDays} días`}`, }, billingBanner: { policyOwnerAmountOwed: { title: 'Tu información de pago está desactualizada', - subtitle: ({date}) => `Actualiza tu tarjeta de pago antes del ${date} para continuar utilizando todas tus herramientas favoritas`, + subtitle: ({date}: BillingBannerSubtitleWithDateParams) => `Actualiza tu tarjeta de pago antes del ${date} para continuar utilizando todas tus herramientas favoritas`, }, policyOwnerAmountOwedOverdue: { title: 'Tu información de pago está desactualizada', @@ -5048,7 +5146,7 @@ export default { }, policyOwnerUnderInvoicing: { title: 'Tu información de pago está desactualizada', - subtitle: ({date}) => `Tu pago está vencido. Por favor, paga tu factura antes del ${date} para evitar la interrupción del servicio.`, + subtitle: ({date}: BillingBannerSubtitleWithDateParams) => `Tu pago está vencido. Por favor, paga tu factura antes del ${date} para evitar la interrupción del servicio.`, }, policyOwnerUnderInvoicingOverdue: { title: 'Tu información de pago está desactualizada', @@ -5056,22 +5154,23 @@ export default { }, billingDisputePending: { title: 'No se ha podido realizar el cobro a tu tarjeta', - subtitle: ({amountOwed, cardEnding}) => + subtitle: ({amountOwed, cardEnding}: BillingBannerDisputePendingParams) => `Has impugnado el cargo ${amountOwed} en la tarjeta terminada en ${cardEnding}. Tu cuenta estará bloqueada hasta que se resuelva la disputa con tu banco.`, }, cardAuthenticationRequired: { title: 'No se ha podido realizar el cobro a tu tarjeta', - subtitle: ({cardEnding}) => + subtitle: ({cardEnding}: BillingBannerCardAuthenticationRequiredParams) => `Tu tarjeta de pago no ha sido autenticada completamente. Por favor, completa el proceso de autenticación para activar tu tarjeta de pago que termina en ${cardEnding}.`, }, insufficientFunds: { title: 'No se ha podido realizar el cobro a tu tarjeta', - subtitle: ({amountOwed}) => + subtitle: ({amountOwed}: BillingBannerInsufficientFundsParams) => `Tu tarjeta de pago fue rechazada por falta de fondos. Vuelve a intentarlo o añade una nueva tarjeta de pago para liquidar tu saldo pendiente de ${amountOwed}.`, }, cardExpired: { title: 'No se ha podido realizar el cobro a tu tarjeta', - subtitle: ({amountOwed}) => `Tu tarjeta de pago ha expirado. Por favor, añade una nueva tarjeta de pago para liquidar tu saldo pendiente de ${amountOwed}.`, + subtitle: ({amountOwed}: BillingBannerCardExpiredParams) => + `Tu tarjeta de pago ha expirado. Por favor, añade una nueva tarjeta de pago para liquidar tu saldo pendiente de ${amountOwed}.`, }, cardExpireSoon: { title: 'Tu tarjeta caducará pronto', @@ -5087,7 +5186,7 @@ export default { subtitle: 'Antes de volver a intentarlo, llama directamente a tu banco para que autorice los cargos de Expensify y elimine las retenciones. De lo contrario, añade una tarjeta de pago diferente.', }, - cardOnDispute: ({amountOwed, cardEnding}) => + cardOnDispute: ({amountOwed, cardEnding}: BillingBannerCardOnDisputeParams) => `Has impugnado el cargo ${amountOwed} en la tarjeta terminada en ${cardEnding}. Tu cuenta estará bloqueada hasta que se resuelva la disputa con tu banco.`, preTrial: { title: 'Iniciar una prueba gratuita', @@ -5096,7 +5195,7 @@ export default { subtitleEnd: 'para que tu equipo pueda empezar a enviar gastos.', }, trialStarted: { - title: ({numOfDays}) => `Prueba gratuita: ¡${numOfDays === 1 ? `queda 1 día` : `quedan ${numOfDays} días`}!`, + title: ({numOfDays}: TrialStartedTitleParams) => `Prueba gratuita: ¡${numOfDays === 1 ? `queda 1 día` : `quedan ${numOfDays} días`}!`, subtitle: 'Añade una tarjeta de pago para seguir utilizando tus funciones favoritas.', }, trialEnded: { @@ -5108,9 +5207,9 @@ export default { title: 'Pago', subtitle: 'Añade una tarjeta para pagar tu suscripción a Expensify.', addCardButton: 'Añade tarjeta de pago', - cardNextPayment: ({nextPaymentDate}) => `Tu próxima fecha de pago es ${nextPaymentDate}.`, - cardEnding: ({cardNumber}) => `Tarjeta terminada en ${cardNumber}`, - cardInfo: ({name, expiration, currency}) => `Nombre: ${name}, Expiración: ${expiration}, Moneda: ${currency}`, + cardNextPayment: ({nextPaymentDate}: CardNextPaymentParams) => `Tu próxima fecha de pago es ${nextPaymentDate}.`, + cardEnding: ({cardNumber}: CardEndingParams) => `Tarjeta terminada en ${cardNumber}`, + cardInfo: ({name, expiration, currency}: CardInfoParams) => `Nombre: ${name}, Expiración: ${expiration}, Moneda: ${currency}`, changeCard: 'Cambiar tarjeta de pago', changeCurrency: 'Cambiar moneda de pago', cardNotFound: 'No se ha añadido ninguna tarjeta de pago', @@ -5129,8 +5228,8 @@ export default { title: 'Tu plan', collect: { title: 'Recolectar', - priceAnnual: ({lower, upper}) => `Desde ${lower}/miembro activo con la Tarjeta Expensify, ${upper}/miembro activo sin la Tarjeta Expensify.`, - pricePayPerUse: ({lower, upper}) => `Desde ${lower}/miembro activo con la Tarjeta Expensify, ${upper}/miembro activo sin la Tarjeta Expensify.`, + priceAnnual: ({lower, upper}: YourPlanPriceParams) => `Desde ${lower}/miembro activo con la Tarjeta Expensify, ${upper}/miembro activo sin la Tarjeta Expensify.`, + pricePayPerUse: ({lower, upper}: YourPlanPriceParams) => `Desde ${lower}/miembro activo con la Tarjeta Expensify, ${upper}/miembro activo sin la Tarjeta Expensify.`, benefit1: 'SmartScans ilimitados y seguimiento de la distancia', benefit2: 'Tarjetas Expensify con Límites Inteligentes', benefit3: 'Pago de facturas y facturación', @@ -5141,8 +5240,8 @@ export default { }, control: { title: 'Control', - priceAnnual: ({lower, upper}) => `Desde ${lower}/miembro activo con la Tarjeta Expensify, ${upper}/miembro activo sin la Tarjeta Expensify.`, - pricePayPerUse: ({lower, upper}) => `Desde ${lower}/miembro activo con la Tarjeta Expensify, ${upper}/miembro activo sin la Tarjeta Expensify.`, + priceAnnual: ({lower, upper}: YourPlanPriceParams) => `Desde ${lower}/miembro activo con la Tarjeta Expensify, ${upper}/miembro activo sin la Tarjeta Expensify.`, + pricePayPerUse: ({lower, upper}: YourPlanPriceParams) => `Desde ${lower}/miembro activo con la Tarjeta Expensify, ${upper}/miembro activo sin la Tarjeta Expensify.`, benefit1: 'Todo en Recolectar, más:', benefit2: 'Integraciones con NetSuite y Sage Intacct', benefit3: 'Sincronización de Certinia y Workday', @@ -5173,10 +5272,10 @@ export default { note: 'Nota: Un miembro activo es cualquiera que haya creado, editado, enviado, aprobado, reembolsado, o exportado datos de gastos vinculados al espacio de trabajo de tu empresa.', confirmDetails: 'Confirma los datos de tu nueva suscripción anual:', subscriptionSize: 'Tamaño de suscripción', - activeMembers: ({size}) => `${size} miembros activos/mes`, + activeMembers: ({size}: SubscriptionSizeParams) => `${size} miembros activos/mes`, subscriptionRenews: 'Renovación de la suscripción', youCantDowngrade: 'No puedes bajar de categoría durante tu suscripción anual.', - youAlreadyCommitted: ({size, date}) => + youAlreadyCommitted: ({size, date}: SubscriptionCommitmentParams) => `Ya se ha comprometido a un tamaño de suscripción anual de ${size} miembros activos al mes hasta el ${date}. Puede cambiar a una suscripción de pago por uso en ${date} desactivando la auto-renovación.`, error: { size: 'Por favor ingrese un tamaño de suscripción valido.', @@ -5193,13 +5292,13 @@ export default { title: 'Configuración de suscripción', autoRenew: 'Auto-renovación', autoIncrease: 'Auto-incremento', - saveUpTo: ({amountWithCurrency}) => `Ahorre hasta ${amountWithCurrency} al mes por miembro activo`, + saveUpTo: ({amountWithCurrency}: SubscriptionSettingsSaveUpToParams) => `Ahorre hasta ${amountWithCurrency} al mes por miembro activo`, automaticallyIncrease: 'Aumenta automáticamente tus plazas anuales para dar lugar a los miembros activos que superen el tamaño de tu suscripción. Nota: Esto ampliará la fecha de finalización de tu suscripción anual.', disableAutoRenew: 'Desactivar auto-renovación', helpUsImprove: 'Ayúdanos a mejorar Expensify', whatsMainReason: '¿Cuál es la razón principal por la que deseas desactivar la auto-renovación?', - renewsOn: ({date}) => `Se renovará el ${date}.`, + renewsOn: ({date}: SubscriptionSettingsRenewsOnParams) => `Se renovará el ${date}.`, }, requestEarlyCancellation: { title: 'Solicitar cancelación anticipada', @@ -5248,7 +5347,7 @@ export default { addCopilot: 'Agregar copiloto', membersCanAccessYourAccount: 'Estos miembros pueden acceder a tu cuenta:', youCanAccessTheseAccounts: 'Puedes acceder a estas cuentas a través del conmutador de cuentas:', - role: (role?: string): string => { + role: ({role}: OptionalParam = {}) => { switch (role) { case CONST.DELEGATE_ROLE.ALL: return 'Completo'; @@ -5259,10 +5358,11 @@ export default { } }, genericError: '¡Ups! Ha ocurrido un error. Por favor, inténtalo de nuevo.', + onBehalfOfMessage: ({delegator}: DelegatorParams) => `en nombre de ${delegator}`, accessLevel: 'Nivel de acceso', confirmCopilot: 'Confirma tu copiloto a continuación.', accessLevelDescription: 'Elige un nivel de acceso a continuación. Tanto el acceso Completo como el Limitado permiten a los copilotos ver todas las conversaciones y gastos.', - roleDescription: (role?: string): string => { + roleDescription: ({role}: OptionalParam = {}) => { switch (role) { case CONST.DELEGATE_ROLE.ALL: return 'Permite a otro miembro realizar todas las acciones en tu cuenta, en tu nombre. Incluye chat, presentaciones, aprobaciones, pagos, actualizaciones de configuración y más.'; @@ -5288,9 +5388,9 @@ export default { nothingToPreview: 'Nada que previsualizar', editJson: 'Editar JSON:', preview: 'Previa:', - missingProperty: ({propertyName}) => `Falta ${propertyName}`, - invalidProperty: ({propertyName, expectedType}) => `Propiedad inválida: ${propertyName} - Esperado: ${expectedType}`, - invalidValue: ({expectedValues}) => `Valor inválido - Esperado: ${expectedValues}`, + missingProperty: ({propertyName}: MissingPropertyParams) => `Falta ${propertyName}`, + invalidProperty: ({propertyName, expectedType}: InvalidPropertyParams) => `Propiedad inválida: ${propertyName} - Esperado: ${expectedType}`, + invalidValue: ({expectedValues}: InvalidValueParams) => `Valor inválido - Esperado: ${expectedValues}`, missingValue: 'Valor en falta', createReportAction: 'Crear Report Action', reportAction: 'Report Action', @@ -5305,4 +5405,6 @@ export default { time: 'Hora', none: 'Ninguno', }, -} satisfies EnglishTranslation; +}; + +export default translations satisfies TranslationDeepObject; diff --git a/src/languages/params.ts b/src/languages/params.ts new file mode 100644 index 000000000000..d51bb2d20e03 --- /dev/null +++ b/src/languages/params.ts @@ -0,0 +1,733 @@ +import type {OnyxInputOrEntry, ReportAction} from '@src/types/onyx'; +import type {DelegateRole} from '@src/types/onyx/Account'; +import type {ConnectionName, PolicyConnectionSyncStage, SageIntacctMappingName, Unit} from '@src/types/onyx/Policy'; +import type {ViolationDataType} from '@src/types/onyx/TransactionViolation'; + +type AddressLineParams = { + lineNumber: number; +}; + +type CharacterLimitParams = { + limit: number | string; +}; + +type AssigneeParams = { + assignee: string; +}; + +type CharacterLengthLimitParams = { + limit: number; + length: number; +}; + +type ZipCodeExampleFormatParams = { + zipSampleFormat: string; +}; + +type LoggedInAsParams = { + email: string; +}; + +type SignUpNewFaceCodeParams = { + login: string; +}; + +type WelcomeEnterMagicCodeParams = { + login: string; +}; + +type AlreadySignedInParams = { + email: string; +}; + +type GoBackMessageParams = { + provider: string; +}; + +type LocalTimeParams = { + user: string; + time: string; +}; + +type EditActionParams = { + action: OnyxInputOrEntry; +}; + +type DeleteActionParams = { + action: OnyxInputOrEntry; +}; + +type DeleteConfirmationParams = { + action: OnyxInputOrEntry; +}; + +type BeginningOfChatHistoryDomainRoomPartOneParams = { + domainRoom: string; +}; + +type BeginningOfChatHistoryAdminRoomPartOneParams = { + workspaceName: string; +}; + +type BeginningOfChatHistoryAnnounceRoomPartOneParams = { + workspaceName: string; +}; + +type BeginningOfChatHistoryAnnounceRoomPartTwo = { + workspaceName: string; +}; + +type WelcomeToRoomParams = { + roomName: string; +}; + +type UsePlusButtonParams = { + additionalText: string; +}; + +type ReportArchiveReasonsClosedParams = { + displayName: string; +}; + +type ReportArchiveReasonsMergedParams = { + displayName: string; + oldDisplayName: string; +}; + +type ReportArchiveReasonsRemovedFromPolicyParams = { + displayName: string; + policyName: string; + shouldUseYou?: boolean; +}; + +type ReportPolicyNameParams = { + policyName: string; +}; + +type ReportArchiveReasonsInvoiceReceiverPolicyDeletedParams = { + policyName: string; +}; + +type RequestCountParams = { + scanningReceipts: number; + pendingReceipts: number; +}; + +type SettleExpensifyCardParams = { + formattedAmount: string; +}; + +type RequestAmountParams = {amount: string}; + +type RequestedAmountMessageParams = {formattedAmount: string; comment?: string}; + +type SplitAmountParams = {amount: string}; + +type DidSplitAmountMessageParams = {formattedAmount: string; comment: string}; + +type UserSplitParams = {amount: string}; + +type PayerOwesAmountParams = {payer: string; amount: number | string; comment?: string}; + +type PayerOwesParams = {payer: string}; + +type CompanyCardFeedNameParams = {feedName: string}; + +type PayerPaidAmountParams = {payer?: string; amount: number | string}; + +type ApprovedAmountParams = {amount: number | string}; + +type ForwardedAmountParams = {amount: number | string}; + +type ManagerApprovedParams = {manager: string}; + +type ManagerApprovedAmountParams = {manager: string; amount: number | string}; + +type PayerPaidParams = {payer: string}; + +type PayerSettledParams = {amount: number | string}; + +type WaitingOnBankAccountParams = {submitterDisplayName: string}; + +type CanceledRequestParams = {amount: string; submitterDisplayName: string}; + +type AdminCanceledRequestParams = {manager: string; amount: string}; + +type SettledAfterAddedBankAccountParams = {submitterDisplayName: string; amount: string}; + +type PaidElsewhereWithAmountParams = {payer?: string; amount: string}; + +type PaidWithExpensifyWithAmountParams = {payer?: string; amount: string}; + +type ThreadRequestReportNameParams = {formattedAmount: string; comment: string}; + +type ThreadSentMoneyReportNameParams = {formattedAmount: string; comment: string}; + +type SizeExceededParams = {maxUploadSizeInMB: number}; + +type ResolutionConstraintsParams = {minHeightInPx: number; minWidthInPx: number; maxHeightInPx: number; maxWidthInPx: number}; + +type NotAllowedExtensionParams = {allowedExtensions: string[]}; + +type EnterMagicCodeParams = {contactMethod: string}; + +type TransferParams = {amount: string}; + +type InstantSummaryParams = {rate: string; minAmount: string}; + +type NotYouParams = {user: string}; + +type DateShouldBeBeforeParams = {dateString: string}; + +type DateShouldBeAfterParams = {dateString: string}; + +type WeSentYouMagicSignInLinkParams = {login: string; loginType: string}; + +type ToValidateLoginParams = {primaryLogin: string; secondaryLogin: string}; + +type NoLongerHaveAccessParams = {primaryLogin: string}; + +type OurEmailProviderParams = {login: string}; + +type ConfirmThatParams = {login: string}; + +type UntilTimeParams = {time: string}; + +type StepCounterParams = {step: number; total?: number; text?: string}; + +type UserIsAlreadyMemberParams = {login: string; name: string}; + +type GoToRoomParams = {roomName: string}; + +type WelcomeNoteParams = {workspaceName: string}; + +type RoomNameReservedErrorParams = {reservedName: string}; + +type RenamedRoomActionParams = {oldName: string; newName: string}; + +type RoomRenamedToParams = {newName: string}; + +type OOOEventSummaryFullDayParams = {summary: string; dayCount: number; date: string}; + +type OOOEventSummaryPartialDayParams = {summary: string; timePeriod: string; date: string}; + +type ParentNavigationSummaryParams = {reportName?: string; workspaceName?: string}; + +type SetTheRequestParams = {valueName: string; newValueToDisplay: string}; + +type SetTheDistanceMerchantParams = {translatedChangedField: string; newMerchant: string; newAmountToDisplay: string}; + +type RemovedTheRequestParams = {valueName: string; oldValueToDisplay: string}; + +type UpdatedTheRequestParams = {valueName: string; newValueToDisplay: string; oldValueToDisplay: string}; + +type UpdatedTheDistanceMerchantParams = {translatedChangedField: string; newMerchant: string; oldMerchant: string; newAmountToDisplay: string; oldAmountToDisplay: string}; + +type FormattedMaxLengthParams = {formattedMaxLength: string}; + +type WalletProgramParams = {walletProgram: string}; + +type ViolationsAutoReportedRejectedExpenseParams = {rejectedBy: string; rejectReason: string}; + +type ViolationsCashExpenseWithNoReceiptParams = {formattedLimit?: string} | undefined; + +type ViolationsConversionSurchargeParams = {surcharge: number}; + +type ViolationsInvoiceMarkupParams = {invoiceMarkup: number}; + +type ViolationsMaxAgeParams = {maxAge: number}; + +type ViolationsMissingTagParams = {tagName?: string} | undefined; + +type ViolationsModifiedAmountParams = {type?: ViolationDataType; displayPercentVariance?: number}; + +type ViolationsOverAutoApprovalLimitParams = {formattedLimit: string}; + +type ViolationsOverCategoryLimitParams = {formattedLimit: string}; + +type ViolationsOverLimitParams = {formattedLimit: string}; + +type ViolationsPerDayLimitParams = {formattedLimit: string}; + +type ViolationsReceiptRequiredParams = {formattedLimit?: string; category?: string}; + +type ViolationsRterParams = { + brokenBankConnection: boolean; + isAdmin: boolean; + email?: string; + isTransactionOlderThan7Days: boolean; + member?: string; +}; + +type ViolationsTagOutOfPolicyParams = {tagName?: string} | undefined; + +type ViolationsTaxOutOfPolicyParams = {taxName?: string} | undefined; + +type PaySomeoneParams = {name?: string} | undefined; + +type TaskCreatedActionParams = {title: string}; + +type OptionalParam = Partial; + +type TermsParams = {amount: string}; + +type ElectronicFundsParams = {percentage: string; amount: string}; + +type LogSizeParams = {size: number}; + +type LogSizeAndDateParams = {size: number; date: string}; + +type HeldRequestParams = {comment: string}; + +type ReimbursementRateParams = {unit: Unit}; + +type ChangeFieldParams = {oldValue?: string; newValue: string; fieldName: string}; + +type ChangePolicyParams = {fromPolicy: string; toPolicy: string}; + +type ChangeTypeParams = {oldType: string; newType: string}; + +type DelegateSubmitParams = {delegateUser: string; originalManager: string}; + +type AccountOwnerParams = {accountOwnerEmail: string}; + +type ExportedToIntegrationParams = {label: string; markedManually?: boolean; inProgress?: boolean; lastModified?: string}; + +type IntegrationsMessageParams = { + label: string; + result: { + code?: number; + messages?: string[]; + title?: string; + link?: { + url: string; + text: string; + }; + }; +}; + +type MarkedReimbursedParams = {amount: string; currency: string}; + +type MarkReimbursedFromIntegrationParams = {amount: string; currency: string}; + +type ShareParams = {to: string}; + +type UnshareParams = {to: string}; + +type StripePaidParams = {amount: string; currency: string}; + +type UnapprovedParams = {amount: string; currency: string}; + +type RemoveMembersWarningPrompt = { + memberName: string; + ownerName: string; +}; + +type RemoveMemberPromptParams = { + memberName: string; +}; + +type IssueVirtualCardParams = { + assignee: string; + link: string; +}; + +type ApprovalWorkflowErrorParams = { + name1: string; + name2: string; +}; + +type ConnectionNameParams = { + connectionName: ConnectionName; +}; + +type LastSyncDateParams = { + connectionName: string; + formattedDate: string; +}; + +type CustomersOrJobsLabelParams = { + importFields: string[]; + importType: string; +}; + +type ExportAgainModalDescriptionParams = { + reportName: string; + connectionName: ConnectionName; +}; + +type IntegrationSyncFailedParams = {label: string; errorMessage: string}; + +type AddEmployeeParams = {email: string; role: string}; + +type UpdateRoleParams = {email: string; currentRole: string; newRole: string}; + +type RemoveMemberParams = {email: string; role: string}; + +type DateParams = {date: string}; + +type FiltersAmountBetweenParams = {greaterThan: string; lessThan: string}; + +type StatementPageTitleParams = {year: string | number; monthName: string}; + +type DisconnectPromptParams = {currentIntegration?: ConnectionName} | undefined; + +type DisconnectTitleParams = {integration?: ConnectionName} | undefined; + +type AmountWithCurrencyParams = {amountWithCurrency: string}; + +type LowerUpperParams = {lower: string; upper: string}; + +type CategoryNameParams = {categoryName: string}; + +type TaxAmountParams = {taxAmount: number}; + +type SecondaryLoginParams = {secondaryLogin: string}; + +type OwnerOwesAmountParams = {amount: string; email: string}; + +type ChangeOwnerSubscriptionParams = {usersCount: number; finalCount: number}; + +type ChangeOwnerDuplicateSubscriptionParams = {email: string; workspaceName: string}; + +type ChangeOwnerHasFailedSettlementsParams = {email: string}; + +type ActionsAreCurrentlyRestricted = {workspaceName: string}; + +type WorkspaceOwnerWillNeedToAddOrUpdatePaymentCardParams = {workspaceOwnerName: string}; + +type RenamedWorkspaceNameActionParams = {oldName: string; newName: string}; + +type StatementTitleParams = {year: number | string; monthName: string}; + +type BadgeFreeTrialParams = {numOfDays: number}; + +type BillingBannerSubtitleWithDateParams = {date: string}; + +type BillingBannerDisputePendingParams = {amountOwed: number; cardEnding: string}; + +type BillingBannerCardAuthenticationRequiredParams = {cardEnding: string}; + +type BillingBannerInsufficientFundsParams = {amountOwed: number}; + +type BillingBannerCardExpiredParams = {amountOwed: number}; + +type BillingBannerCardOnDisputeParams = {amountOwed: string; cardEnding: string}; + +type TrialStartedTitleParams = {numOfDays: number}; + +type CardNextPaymentParams = {nextPaymentDate: string}; + +type CardEndingParams = {cardNumber: string}; + +type CardInfoParams = {name: string; expiration: string; currency: string}; + +type YourPlanPriceParams = {lower: string; upper: string}; + +type SubscriptionSizeParams = {size: number}; + +type SubscriptionCommitmentParams = {size: number; date: string}; + +type SubscriptionSettingsSaveUpToParams = {amountWithCurrency: string}; + +type SubscriptionSettingsRenewsOnParams = {date: string}; + +type UnapproveWithIntegrationWarningParams = {accountingIntegration: string}; + +type IncorrectZipFormatParams = {zipFormat?: string} | undefined; + +type ExportIntegrationSelectedParams = {connectionName: ConnectionName}; + +type DefaultVendorDescriptionParams = {isReimbursable: boolean}; + +type RequiredFieldParams = {fieldName: string}; + +type ImportFieldParams = {importField: string}; + +type IntacctMappingTitleParams = {mappingName: SageIntacctMappingName}; + +type LastSyncAccountingParams = {relativeDate: string}; + +type SyncStageNameConnectionsParams = {stage: PolicyConnectionSyncStage}; + +type ReconciliationWorksParams = {lastFourPAN: string}; + +type DelegateRoleParams = {role: DelegateRole}; + +type DelegatorParams = {delegator: string}; + +type RoleNamesParams = {role: string}; + +type AssignCardParams = { + assignee: string; + feed: string; +}; + +type SpreadSheetColumnParams = { + name: string; +}; + +type SpreadFieldNameParams = { + fieldName: string; +}; + +type SpreadCategoriesParams = { + categories: number; +}; + +type AssignedYouCardParams = { + assigner: string; +}; + +type FeatureNameParams = { + featureName: string; +}; + +type AutoPayApprovedReportsLimitErrorParams = { + currency?: string; +}; + +type DefaultAmountParams = { + defaultAmount: string; +}; + +type RemovedFromApprovalWorkflowParams = { + submittersNames: string[]; +}; + +type IntegrationExportParams = { + integration: string; + type?: string; +}; + +type ConnectionParams = { + connection: string; +}; + +type MissingPropertyParams = { + propertyName: string; +}; + +type InvalidPropertyParams = { + propertyName: string; + expectedType: string; +}; + +type InvalidValueParams = { + expectedValues: string; +}; + +type ImportTagsSuccessfullDescriptionParams = { + tags: number; +}; + +type ImportedTagsMessageParams = { + columnCounts: number; +}; + +type ImportMembersSuccessfullDescriptionParams = { + members: number; +}; + +type AuthenticationErrorParams = { + connectionName: string; +}; + +export type { + AuthenticationErrorParams, + ImportMembersSuccessfullDescriptionParams, + ImportedTagsMessageParams, + ImportTagsSuccessfullDescriptionParams, + MissingPropertyParams, + InvalidPropertyParams, + InvalidValueParams, + ConnectionParams, + IntegrationExportParams, + RemovedFromApprovalWorkflowParams, + DefaultAmountParams, + AutoPayApprovedReportsLimitErrorParams, + FeatureNameParams, + SpreadSheetColumnParams, + SpreadFieldNameParams, + AssignedYouCardParams, + SpreadCategoriesParams, + DelegateRoleParams, + DelegatorParams, + ReconciliationWorksParams, + LastSyncAccountingParams, + SyncStageNameConnectionsParams, + RequiredFieldParams, + IntacctMappingTitleParams, + ImportFieldParams, + AssigneeParams, + DefaultVendorDescriptionParams, + ExportIntegrationSelectedParams, + UnapproveWithIntegrationWarningParams, + IncorrectZipFormatParams, + CardNextPaymentParams, + CardEndingParams, + CardInfoParams, + YourPlanPriceParams, + SubscriptionSizeParams, + SubscriptionCommitmentParams, + SubscriptionSettingsSaveUpToParams, + SubscriptionSettingsRenewsOnParams, + BadgeFreeTrialParams, + BillingBannerSubtitleWithDateParams, + BillingBannerDisputePendingParams, + BillingBannerCardAuthenticationRequiredParams, + BillingBannerInsufficientFundsParams, + BillingBannerCardExpiredParams, + BillingBannerCardOnDisputeParams, + TrialStartedTitleParams, + RemoveMemberPromptParams, + StatementTitleParams, + RenamedWorkspaceNameActionParams, + WorkspaceOwnerWillNeedToAddOrUpdatePaymentCardParams, + ActionsAreCurrentlyRestricted, + ChangeOwnerHasFailedSettlementsParams, + OwnerOwesAmountParams, + ChangeOwnerDuplicateSubscriptionParams, + ChangeOwnerSubscriptionParams, + SecondaryLoginParams, + TaxAmountParams, + CategoryNameParams, + AmountWithCurrencyParams, + LowerUpperParams, + LogSizeAndDateParams, + AddressLineParams, + AdminCanceledRequestParams, + AlreadySignedInParams, + ApprovedAmountParams, + BeginningOfChatHistoryAdminRoomPartOneParams, + BeginningOfChatHistoryAnnounceRoomPartOneParams, + BeginningOfChatHistoryAnnounceRoomPartTwo, + BeginningOfChatHistoryDomainRoomPartOneParams, + CanceledRequestParams, + CharacterLimitParams, + ConfirmThatParams, + CompanyCardFeedNameParams, + DateShouldBeAfterParams, + DateShouldBeBeforeParams, + DeleteActionParams, + DeleteConfirmationParams, + DidSplitAmountMessageParams, + EditActionParams, + ElectronicFundsParams, + EnterMagicCodeParams, + FormattedMaxLengthParams, + ForwardedAmountParams, + GoBackMessageParams, + GoToRoomParams, + HeldRequestParams, + InstantSummaryParams, + IssueVirtualCardParams, + LocalTimeParams, + LogSizeParams, + LoggedInAsParams, + ManagerApprovedAmountParams, + ManagerApprovedParams, + SignUpNewFaceCodeParams, + NoLongerHaveAccessParams, + NotAllowedExtensionParams, + NotYouParams, + OOOEventSummaryFullDayParams, + OOOEventSummaryPartialDayParams, + OurEmailProviderParams, + PaidElsewhereWithAmountParams, + PaidWithExpensifyWithAmountParams, + ParentNavigationSummaryParams, + PaySomeoneParams, + PayerOwesAmountParams, + PayerOwesParams, + RoleNamesParams, + PayerPaidAmountParams, + PayerPaidParams, + PayerSettledParams, + ReimbursementRateParams, + RemovedTheRequestParams, + RenamedRoomActionParams, + ReportArchiveReasonsClosedParams, + ReportArchiveReasonsMergedParams, + ReportPolicyNameParams, + ReportArchiveReasonsInvoiceReceiverPolicyDeletedParams, + ReportArchiveReasonsRemovedFromPolicyParams, + RequestAmountParams, + RequestCountParams, + RequestedAmountMessageParams, + ResolutionConstraintsParams, + RoomNameReservedErrorParams, + RoomRenamedToParams, + SetTheDistanceMerchantParams, + SetTheRequestParams, + SettleExpensifyCardParams, + SettledAfterAddedBankAccountParams, + SizeExceededParams, + SplitAmountParams, + StepCounterParams, + TaskCreatedActionParams, + TermsParams, + ThreadRequestReportNameParams, + ThreadSentMoneyReportNameParams, + ToValidateLoginParams, + TransferParams, + UntilTimeParams, + UpdatedTheDistanceMerchantParams, + UpdatedTheRequestParams, + UsePlusButtonParams, + UserIsAlreadyMemberParams, + UserSplitParams, + ViolationsAutoReportedRejectedExpenseParams, + ViolationsCashExpenseWithNoReceiptParams, + ViolationsConversionSurchargeParams, + ViolationsInvoiceMarkupParams, + ViolationsMaxAgeParams, + ViolationsMissingTagParams, + ViolationsModifiedAmountParams, + ViolationsOverAutoApprovalLimitParams, + ViolationsOverCategoryLimitParams, + ViolationsOverLimitParams, + ViolationsPerDayLimitParams, + ViolationsReceiptRequiredParams, + ViolationsRterParams, + ViolationsTagOutOfPolicyParams, + ViolationsTaxOutOfPolicyParams, + WaitingOnBankAccountParams, + WalletProgramParams, + WeSentYouMagicSignInLinkParams, + WelcomeEnterMagicCodeParams, + WelcomeNoteParams, + WelcomeToRoomParams, + ZipCodeExampleFormatParams, + ChangeFieldParams, + ChangePolicyParams, + ChangeTypeParams, + ExportedToIntegrationParams, + DelegateSubmitParams, + AccountOwnerParams, + IntegrationsMessageParams, + MarkedReimbursedParams, + MarkReimbursedFromIntegrationParams, + ShareParams, + UnshareParams, + StripePaidParams, + UnapprovedParams, + RemoveMembersWarningPrompt, + ApprovalWorkflowErrorParams, + ConnectionNameParams, + LastSyncDateParams, + CustomersOrJobsLabelParams, + ExportAgainModalDescriptionParams, + IntegrationSyncFailedParams, + AddEmployeeParams, + UpdateRoleParams, + RemoveMemberParams, + DateParams, + FiltersAmountBetweenParams, + StatementPageTitleParams, + DisconnectPromptParams, + DisconnectTitleParams, + CharacterLengthLimitParams, + OptionalParam, + AssignCardParams, +}; diff --git a/src/languages/translations.ts b/src/languages/translations.ts index 4d89f1f529de..ec99d999f94e 100644 --- a/src/languages/translations.ts +++ b/src/languages/translations.ts @@ -1,7 +1,7 @@ import en from './en'; import es from './es'; import esES from './es-ES'; -import type {TranslationBase, TranslationFlatObject} from './types'; +import type {FlatTranslationsObject, TranslationDeepObject} from './types'; /** * Converts an object to it's flattened version. @@ -12,10 +12,10 @@ import type {TranslationBase, TranslationFlatObject} from './types'; */ // Necessary to export so that it is accessible to the unit tests // eslint-disable-next-line rulesdir/no-inline-named-export -export function flattenObject(obj: TranslationBase): TranslationFlatObject { +export function flattenObject(obj: TranslationDeepObject): FlatTranslationsObject { const result: Record = {}; - const recursive = (data: TranslationBase, key: string): void => { + const recursive = (data: TranslationDeepObject, key: string): void => { // If the data is a function or not a object (eg. a string or array), // it's the final value for the key being built and there is no need // for more recursion @@ -27,7 +27,7 @@ export function flattenObject(obj: TranslationBase): TranslationFlatObject { // Recursive call to the keys and connect to the respective data Object.keys(data).forEach((k) => { isEmpty = false; - recursive(data[k] as TranslationBase, key ? `${key}.${k}` : k); + recursive(data[k] as TranslationDeepObject, key ? `${key}.${k}` : k); }); // Check for when the object is empty but a key exists, so that @@ -39,7 +39,7 @@ export function flattenObject(obj: TranslationBase): TranslationFlatObject { }; recursive(obj, ''); - return result as TranslationFlatObject; + return result as FlatTranslationsObject; } export default { diff --git a/src/languages/types.ts b/src/languages/types.ts index a7a11fafb27b..0bdf740d982e 100644 --- a/src/languages/types.ts +++ b/src/languages/types.ts @@ -1,278 +1,53 @@ -import type {OnyxInputOrEntry, ReportAction} from '@src/types/onyx'; -import type {Unit} from '@src/types/onyx/Policy'; -import type {ViolationDataType} from '@src/types/onyx/TransactionViolation'; +/* eslint-disable @typescript-eslint/no-explicit-any */ import type en from './en'; -type AddressLineParams = { - lineNumber: number; -}; - -type CharacterLimitParams = { - limit: number; -}; - -type ZipCodeExampleFormatParams = { - zipSampleFormat: string; -}; - -type LoggedInAsParams = { - email: string; -}; - -type SignUpNewFaceCodeParams = { - login: string; -}; - -type WelcomeEnterMagicCodeParams = { - login: string; -}; - -type AlreadySignedInParams = { - email: string; -}; - -type GoBackMessageParams = { - provider: string; -}; - -type LocalTimeParams = { - user: string; - time: string; -}; - -type EditActionParams = { - action: OnyxInputOrEntry; -}; - -type DeleteActionParams = { - action: OnyxInputOrEntry; -}; - -type DeleteConfirmationParams = { - action: OnyxInputOrEntry; -}; - -type BeginningOfChatHistoryDomainRoomPartOneParams = { - domainRoom: string; -}; - -type BeginningOfChatHistoryAdminRoomPartOneParams = { - workspaceName: string; -}; - -type BeginningOfChatHistoryAnnounceRoomPartOneParams = { - workspaceName: string; -}; - -type BeginningOfChatHistoryAnnounceRoomPartTwo = { - workspaceName: string; -}; - -type WelcomeToRoomParams = { - roomName: string; -}; - -type UsePlusButtonParams = { - additionalText: string; -}; - -type ReportArchiveReasonsClosedParams = { - displayName: string; -}; - -type ReportArchiveReasonsMergedParams = { - displayName: string; - oldDisplayName: string; -}; - -type ReportArchiveReasonsRemovedFromPolicyParams = { - displayName: string; - policyName: string; - shouldUseYou?: boolean; -}; - -type ReportArchiveReasonsPolicyDeletedParams = { - policyName: string; -}; - -type ReportArchiveReasonsInvoiceReceiverPolicyDeletedParams = { - policyName: string; -}; - -type RequestCountParams = { - count: number; - scanningReceipts: number; - pendingReceipts: number; -}; - -type SettleExpensifyCardParams = { - formattedAmount: string; -}; - -type RequestAmountParams = {amount: string}; - -type RequestedAmountMessageParams = {formattedAmount: string; comment?: string}; - -type SplitAmountParams = {amount: string}; - -type DidSplitAmountMessageParams = {formattedAmount: string; comment: string}; - -type UserSplitParams = {amount: string}; - -type PayerOwesAmountParams = {payer: string; amount: number | string; comment?: string}; - -type PayerOwesParams = {payer: string}; - -type CompanyCardFeedNameParams = {feedName: string}; - -type PayerPaidAmountParams = {payer?: string; amount: number | string}; - -type ApprovedAmountParams = {amount: number | string}; - -type ForwardedAmountParams = {amount: number | string}; - -type ManagerApprovedParams = {manager: string}; - -type ManagerApprovedAmountParams = {manager: string; amount: number | string}; - -type PayerPaidParams = {payer: string}; - -type PayerSettledParams = {amount: number | string}; - -type WaitingOnBankAccountParams = {submitterDisplayName: string}; - -type CanceledRequestParams = {amount: string; submitterDisplayName: string}; - -type AdminCanceledRequestParams = {manager: string; amount: string}; - -type SettledAfterAddedBankAccountParams = {submitterDisplayName: string; amount: string}; - -type PaidElsewhereWithAmountParams = {payer?: string; amount: string}; - -type PaidWithExpensifyWithAmountParams = {payer?: string; amount: string}; - -type ThreadRequestReportNameParams = {formattedAmount: string; comment: string}; - -type ThreadSentMoneyReportNameParams = {formattedAmount: string; comment: string}; - -type SizeExceededParams = {maxUploadSizeInMB: number}; - -type ResolutionConstraintsParams = {minHeightInPx: number; minWidthInPx: number; maxHeightInPx: number; maxWidthInPx: number}; - -type NotAllowedExtensionParams = {allowedExtensions: string[]}; - -type EnterMagicCodeParams = {contactMethod: string}; - -type TransferParams = {amount: string}; - -type InstantSummaryParams = {rate: string; minAmount: string}; - -type NotYouParams = {user: string}; - -type DateShouldBeBeforeParams = {dateString: string}; - -type DateShouldBeAfterParams = {dateString: string}; - -type WeSentYouMagicSignInLinkParams = {login: string; loginType: string}; - -type ToValidateLoginParams = {primaryLogin: string; secondaryLogin: string}; - -type NoLongerHaveAccessParams = {primaryLogin: string}; - -type OurEmailProviderParams = {login: string}; - -type ConfirmThatParams = {login: string}; - -type UntilTimeParams = {time: string}; - -type StepCounterParams = {step: number; total?: number; text?: string}; - -type UserIsAlreadyMemberParams = {login: string; name: string}; - -type GoToRoomParams = {roomName: string}; - -type WelcomeNoteParams = {workspaceName: string}; - -type RoomNameReservedErrorParams = {reservedName: string}; - -type RenamedRoomActionParams = {oldName: string; newName: string}; - -type RoomRenamedToParams = {newName: string}; - -type OOOEventSummaryFullDayParams = {summary: string; dayCount: number; date: string}; - -type OOOEventSummaryPartialDayParams = {summary: string; timePeriod: string; date: string}; - -type ParentNavigationSummaryParams = {reportName?: string; workspaceName?: string}; - -type SetTheRequestParams = {valueName: string; newValueToDisplay: string}; - -type SetTheDistanceMerchantParams = {translatedChangedField: string; newMerchant: string; newAmountToDisplay: string}; - -type RemovedTheRequestParams = {valueName: string; oldValueToDisplay: string}; - -type UpdatedTheRequestParams = {valueName: string; newValueToDisplay: string; oldValueToDisplay: string}; - -type UpdatedTheDistanceMerchantParams = {translatedChangedField: string; newMerchant: string; oldMerchant: string; newAmountToDisplay: string; oldAmountToDisplay: string}; - -type FormattedMaxLengthParams = {formattedMaxLength: string}; - -type WalletProgramParams = {walletProgram: string}; - -type ViolationsAutoReportedRejectedExpenseParams = {rejectedBy: string; rejectReason: string}; - -type ViolationsCashExpenseWithNoReceiptParams = {formattedLimit?: string}; - -type ViolationsConversionSurchargeParams = {surcharge?: number}; - -type ViolationsInvoiceMarkupParams = {invoiceMarkup?: number}; - -type ViolationsMaxAgeParams = {maxAge: number}; - -type ViolationsMissingTagParams = {tagName?: string}; - -type ViolationsModifiedAmountParams = {type?: ViolationDataType; displayPercentVariance?: number}; - -type ViolationsOverAutoApprovalLimitParams = {formattedLimit?: string}; - -type ViolationsOverCategoryLimitParams = {formattedLimit?: string}; - -type ViolationsOverLimitParams = {formattedLimit?: string}; - -type ViolationsPerDayLimitParams = {formattedLimit?: string}; - -type ViolationsReceiptRequiredParams = {formattedLimit?: string; category?: string}; - -type ViolationsRterParams = { - brokenBankConnection: boolean; - isAdmin: boolean; - email?: string; - isTransactionOlderThan7Days: boolean; - member?: string; -}; - -type ViolationsTagOutOfPolicyParams = {tagName?: string}; - -type ViolationsTaxOutOfPolicyParams = {taxName?: string}; - -type PaySomeoneParams = {name?: string}; - -type TaskCreatedActionParams = {title: string}; - -/* Translation Object types */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -type TranslationBaseValue = string | string[] | ((...args: any[]) => string); - -type TranslationBase = {[key: string]: TranslationBaseValue | TranslationBase}; - -/* Flat Translation Object types */ -// Flattens an object and returns concatenations of all the keys of nested objects -type FlattenObject = { +type PluralParams = {count: number}; +type PluralHandler = ((count: number) => string) | string; +type PluralForm = { + zero?: string; + one: string; + two?: string; + few?: PluralHandler; + many?: PluralHandler; + other: PluralHandler; +}; + +/** + * Retrieves the first argument of a function + */ +type FirstArgument = TFunction extends (arg: infer A, ...args: any[]) => any ? A : never; + +/** + * Translation value can be a string or a function that returns a string + */ +type TranslationLeafValue = TStringOrFunction extends string + ? string + : ( + arg: FirstArgument extends Record | undefined ? FirstArgument : Record, + ...noOtherArguments: unknown[] + ) => string | PluralForm; + +/** + * Translation object is a recursive object that can contain other objects or string/function values + */ +type TranslationDeepObject = { // eslint-disable-next-line @typescript-eslint/no-explicit-any - [TKey in keyof TObject]: TObject[TKey] extends (...args: any[]) => any - ? `${TPrefix}${TKey & string}` - : // eslint-disable-next-line @typescript-eslint/no-explicit-any - TObject[TKey] extends any[] + [Path in keyof TTranslations]: TTranslations[Path] extends string | ((...args: any[]) => any) + ? TranslationLeafValue + : TTranslations[Path] extends number | boolean | null | undefined | unknown[] + ? string + : TranslationDeepObject; +}; + +/** + * Flattens an object and returns concatenations of all the keys of nested objects + * + * Ex: + * Input: { common: { yes: "Yes", no: "No" }} + * Output: "common.yes" | "common.no" + */ +type FlattenObject = { + [TKey in keyof TObject]: TObject[TKey] extends (arg: any) => any ? `${TPrefix}${TKey & string}` : // eslint-disable-next-line @typescript-eslint/ban-types TObject[TKey] extends object @@ -280,222 +55,43 @@ type FlattenObject = { : `${TPrefix}${TKey & string}`; }[keyof TObject]; -// Retrieves a type for a given key path (calculated from the type above) -type TranslateType = TPath extends keyof TObject - ? TObject[TPath] - : TPath extends `${infer TKey}.${infer TRest}` - ? TKey extends keyof TObject - ? TranslateType +/** + * Retrieves a type for a given key path (calculated from the type above) + */ +type TranslationValue = TKey extends keyof TObject + ? TObject[TKey] + : TKey extends `${infer TPathKey}.${infer TRest}` + ? TPathKey extends keyof TObject + ? TranslationValue : never : never; -type EnglishTranslation = typeof en; - -type TranslationPaths = FlattenObject; - -type TranslationFlatObject = { - [TKey in TranslationPaths]: TranslateType; -}; - -type TermsParams = {amount: string}; - -type ElectronicFundsParams = {percentage: string; amount: string}; - -type LogSizeParams = {size: number}; - -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}; - -type ChangeTypeParams = {oldType: string; newType: string}; - -type DelegateSubmitParams = {delegateUser: string; originalManager: string}; - -type AccountOwnerParams = {accountOwnerEmail: string}; - -type ExportedToIntegrationParams = {label: string; markedManually?: boolean; inProgress?: boolean; lastModified?: string}; - -type IntegrationsMessageParams = { - label: string; - result: { - code?: number; - messages?: string[]; - title?: string; - link?: { - url: string; - text: string; - }; - }; -}; - -type MarkedReimbursedParams = {amount: string; currency: string}; - -type MarkReimbursedFromIntegrationParams = {amount: string; currency: string}; - -type ShareParams = {to: string}; - -type UnshareParams = {to: string}; - -type StripePaidParams = {amount: string; currency: string}; - -type UnapprovedParams = {amount: string; currency: string}; -type RemoveMembersWarningPrompt = { - memberName: string; - ownerName: string; -}; - -type DeleteExpenseTranslationParams = { - count: number; -}; - -type IssueVirtualCardParams = { - assignee: string; - link: string; -}; - -type ApprovalWorkflowErrorParams = { - name1: string; - name2: string; -}; - -type AssignCardParams = { - assignee: string; - feed: string; -}; - -export type { - AddressLineParams, - AdminCanceledRequestParams, - AlreadySignedInParams, - ApprovedAmountParams, - BeginningOfChatHistoryAdminRoomPartOneParams, - BeginningOfChatHistoryAnnounceRoomPartOneParams, - BeginningOfChatHistoryAnnounceRoomPartTwo, - BeginningOfChatHistoryDomainRoomPartOneParams, - CanceledRequestParams, - CharacterLimitParams, - ConfirmHoldExpenseParams, - ConfirmThatParams, - CompanyCardFeedNameParams, - DateShouldBeAfterParams, - DateShouldBeBeforeParams, - DeleteActionParams, - DeleteConfirmationParams, - DidSplitAmountMessageParams, - DistanceRateOperationsParams, - EditActionParams, - ElectronicFundsParams, - EnglishTranslation, - EnterMagicCodeParams, - FormattedMaxLengthParams, - ForwardedAmountParams, - GoBackMessageParams, - GoToRoomParams, - HeldRequestParams, - InstantSummaryParams, - IssueVirtualCardParams, - LocalTimeParams, - LogSizeParams, - LoggedInAsParams, - ManagerApprovedAmountParams, - ManagerApprovedParams, - SignUpNewFaceCodeParams, - NoLongerHaveAccessParams, - NotAllowedExtensionParams, - NotYouParams, - OOOEventSummaryFullDayParams, - OOOEventSummaryPartialDayParams, - OurEmailProviderParams, - PaidElsewhereWithAmountParams, - PaidWithExpensifyWithAmountParams, - ParentNavigationSummaryParams, - PaySomeoneParams, - PayerOwesAmountParams, - PayerOwesParams, - PayerPaidAmountParams, - PayerPaidParams, - PayerSettledParams, - ReimbursementRateParams, - RemovedTheRequestParams, - RenamedRoomActionParams, - ReportArchiveReasonsClosedParams, - ReportArchiveReasonsMergedParams, - ReportArchiveReasonsPolicyDeletedParams, - ReportArchiveReasonsInvoiceReceiverPolicyDeletedParams, - ReportArchiveReasonsRemovedFromPolicyParams, - RequestAmountParams, - RequestCountParams, - RequestedAmountMessageParams, - ResolutionConstraintsParams, - RoomNameReservedErrorParams, - RoomRenamedToParams, - SetTheDistanceMerchantParams, - SetTheRequestParams, - SettleExpensifyCardParams, - SettledAfterAddedBankAccountParams, - SizeExceededParams, - SplitAmountParams, - StepCounterParams, - TaskCreatedActionParams, - TermsParams, - ThreadRequestReportNameParams, - ThreadSentMoneyReportNameParams, - ToValidateLoginParams, - TransferParams, - TranslationBase, - TranslationFlatObject, - TranslationPaths, - UntilTimeParams, - UpdatedTheDistanceMerchantParams, - UpdatedTheRequestParams, - UsePlusButtonParams, - UserIsAlreadyMemberParams, - UserSplitParams, - ViolationsAutoReportedRejectedExpenseParams, - ViolationsCashExpenseWithNoReceiptParams, - ViolationsConversionSurchargeParams, - ViolationsInvoiceMarkupParams, - ViolationsMaxAgeParams, - ViolationsMissingTagParams, - ViolationsModifiedAmountParams, - ViolationsOverAutoApprovalLimitParams, - ViolationsOverCategoryLimitParams, - ViolationsOverLimitParams, - ViolationsPerDayLimitParams, - ViolationsReceiptRequiredParams, - ViolationsRterParams, - ViolationsTagOutOfPolicyParams, - ViolationsTaxOutOfPolicyParams, - WaitingOnBankAccountParams, - WalletProgramParams, - WeSentYouMagicSignInLinkParams, - WelcomeEnterMagicCodeParams, - WelcomeNoteParams, - WelcomeToRoomParams, - ZipCodeExampleFormatParams, - ChangeFieldParams, - ChangePolicyParams, - ChangeTypeParams, - ExportedToIntegrationParams, - DelegateSubmitParams, - AccountOwnerParams, - IntegrationsMessageParams, - MarkedReimbursedParams, - MarkReimbursedFromIntegrationParams, - ShareParams, - UnshareParams, - StripePaidParams, - UnapprovedParams, - RemoveMembersWarningPrompt, - DeleteExpenseTranslationParams, - ApprovalWorkflowErrorParams, - AssignCardParams, -}; +/** + * English is the default translation, other languages will be type-safe based on this + */ +type DefaultTranslation = typeof en; + +/** + * Flattened default translation object + */ +type TranslationPaths = FlattenObject; + +/** + * Flattened default translation object with its values + */ +type FlatTranslationsObject = { + [Path in TranslationPaths]: TranslationValue; +}; + +/** + * Determines the expected parameters for a specific translation function based on the provided translation path + */ +type TranslationParameters = FlatTranslationsObject[TKey] extends (...args: infer Args) => infer Return + ? Return extends PluralForm + ? Args[0] extends undefined + ? [PluralParams] + : [Args[0] & PluralParams] + : Args + : never[]; + +export type {DefaultTranslation, TranslationDeepObject, TranslationPaths, PluralForm, TranslationValue, FlatTranslationsObject, TranslationParameters}; 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/API/parameters/SetPolicyAutoReimbursementLimit.ts b/src/libs/API/parameters/SetPolicyAutoReimbursementLimit.ts index 7c6a721e03b0..b743369db926 100644 --- a/src/libs/API/parameters/SetPolicyAutoReimbursementLimit.ts +++ b/src/libs/API/parameters/SetPolicyAutoReimbursementLimit.ts @@ -1,6 +1,6 @@ type SetPolicyAutoReimbursementLimitParams = { policyID: string; - autoReimbursement: {limit: number}; + limit: number; }; export default SetPolicyAutoReimbursementLimitParams; diff --git a/src/libs/CategoryUtils.ts b/src/libs/CategoryUtils.ts index 7b2f71dbd101..f27b32360a84 100644 --- a/src/libs/CategoryUtils.ts +++ b/src/libs/CategoryUtils.ts @@ -38,10 +38,9 @@ function formatRequireReceiptsOverText(translate: LocaleContextProps['translate' const maxExpenseAmountToDisplay = policy?.maxExpenseAmount === CONST.DISABLED_MAX_EXPENSE_VALUE ? 0 : policy?.maxExpenseAmount; - return translate( - `workspace.rules.categoryRules.requireReceiptsOverList.default`, - CurrencyUtils.convertToShortDisplayString(maxExpenseAmountToDisplay, policy?.outputCurrency ?? CONST.CURRENCY.USD), - ); + return translate(`workspace.rules.categoryRules.requireReceiptsOverList.default`, { + defaultAmount: CurrencyUtils.convertToShortDisplayString(maxExpenseAmountToDisplay, policy?.outputCurrency ?? CONST.CURRENCY.USD), + }); } function getCategoryApproverRule(approvalRules: ApprovalRule[], categoryName: 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/ErrorUtils.ts b/src/libs/ErrorUtils.ts index cf852e533a20..78821cde5e13 100644 --- a/src/libs/ErrorUtils.ts +++ b/src/libs/ErrorUtils.ts @@ -1,14 +1,14 @@ import mapValues from 'lodash/mapValues'; import type {OnyxEntry} from 'react-native-onyx'; import CONST from '@src/CONST'; -import type {TranslationFlatObject, TranslationPaths} from '@src/languages/types'; +import type {TranslationPaths} from '@src/languages/types'; import type {ErrorFields, Errors} from '@src/types/onyx/OnyxCommon'; import type Response from '@src/types/onyx/Response'; import type {ReceiptError} from '@src/types/onyx/Transaction'; import DateUtils from './DateUtils'; import * as Localize from './Localize'; -function getAuthenticateErrorMessage(response: Response): keyof TranslationFlatObject { +function getAuthenticateErrorMessage(response: Response): TranslationPaths { switch (response.jsonCode) { case CONST.JSON_CODE.UNABLE_TO_RETRY: return 'session.offlineMessageRetry'; 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/Localize/index.ts b/src/libs/Localize/index.ts index c9eef3170245..bd8a34406846 100644 --- a/src/libs/Localize/index.ts +++ b/src/libs/Localize/index.ts @@ -6,7 +6,7 @@ import type {MessageElementBase, MessageTextElement} from '@libs/MessageElement' import Config from '@src/CONFIG'; import CONST from '@src/CONST'; import translations from '@src/languages/translations'; -import type {TranslationFlatObject, TranslationPaths} from '@src/languages/types'; +import type {PluralForm, TranslationParameters, TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Locale} from '@src/types/onyx'; import LocaleListener from './LocaleListener'; @@ -45,9 +45,6 @@ function init() { }, {}); } -type PhraseParameters = T extends (...args: infer A) => string ? A : never[]; -type Phrase = TranslationFlatObject[TKey] extends (...args: infer A) => unknown ? (...args: A) => string : string; - /** * Map to store translated values for each locale. * This is used to avoid translating the same phrase multiple times. @@ -82,17 +79,12 @@ const translationCache = new Map, Map( language: 'en' | 'es' | 'es-ES', phraseKey: TKey, - fallbackLanguage: 'en' | 'es' | null = null, - ...phraseParameters: PhraseParameters> + fallbackLanguage: 'en' | 'es' | null, + ...parameters: TranslationParameters ): string | null { // Get the cache for the above locale const cacheForLocale = translationCache.get(language); @@ -106,11 +98,44 @@ function getTranslatedPhrase( return valueFromCache; } - const translatedPhrase = translations?.[language]?.[phraseKey] as Phrase; + const translatedPhrase = translations?.[language]?.[phraseKey]; if (translatedPhrase) { if (typeof translatedPhrase === 'function') { - return translatedPhrase(...phraseParameters); + /** + * If the result of `translatedPhrase` is an object, check if it contains the 'count' parameter + * to handle pluralization logic. + * Alternatively, before evaluating the translated result, we can check if the 'count' parameter + * exists in the passed parameters. + */ + const translateFunction = translatedPhrase as unknown as (...parameters: TranslationParameters) => string | PluralForm; + const translateResult = translateFunction(...parameters); + + if (typeof translateResult === 'string') { + return translateResult; + } + + const phraseObject = parameters[0] as {count?: number}; + if (typeof phraseObject?.count !== 'number') { + throw new Error(`Invalid plural form for '${phraseKey}'`); + } + + const pluralRule = new Intl.PluralRules(language).select(phraseObject.count); + + const pluralResult = translateResult[pluralRule]; + if (pluralResult) { + if (typeof pluralResult === 'string') { + return pluralResult; + } + + return pluralResult(phraseObject.count); + } + + if (typeof translateResult.other === 'string') { + return translateResult.other; + } + + return translateResult.other(phraseObject.count); } // We set the translated value in the cache only for the phrases without parameters. @@ -123,10 +148,10 @@ function getTranslatedPhrase( } // Phrase is not found in full locale, search it in fallback language e.g. es - const fallbacktranslatedPhrase = getTranslatedPhrase(fallbackLanguage, phraseKey, null, ...phraseParameters); + const fallbackTranslatedPhrase = getTranslatedPhrase(fallbackLanguage, phraseKey, null, ...parameters); - if (fallbacktranslatedPhrase) { - return fallbacktranslatedPhrase; + if (fallbackTranslatedPhrase) { + return fallbackTranslatedPhrase; } if (fallbackLanguage !== CONST.LOCALES.DEFAULT) { @@ -134,22 +159,22 @@ function getTranslatedPhrase( } // Phrase is not translated, search it in default language (en) - return getTranslatedPhrase(CONST.LOCALES.DEFAULT, phraseKey, null, ...phraseParameters); + return getTranslatedPhrase(CONST.LOCALES.DEFAULT, phraseKey, null, ...parameters); } /** * Return translated string for given locale and phrase * * @param [desiredLanguage] eg 'en', 'es-ES' - * @param [phraseParameters] Parameters to supply if the phrase is a template literal. + * @param [parameters] Parameters to supply if the phrase is a template literal. */ -function translate(desiredLanguage: 'en' | 'es' | 'es-ES' | 'es_ES', phraseKey: TKey, ...phraseParameters: PhraseParameters>): string { +function translate(desiredLanguage: 'en' | 'es' | 'es-ES' | 'es_ES', path: TPath, ...parameters: TranslationParameters): string { // Search phrase in full locale e.g. es-ES const language = desiredLanguage === CONST.LOCALES.ES_ES_ONFIDO ? CONST.LOCALES.ES_ES : desiredLanguage; // Phrase is not found in full locale, search it in fallback language e.g. es const languageAbbreviation = desiredLanguage.substring(0, 2) as 'en' | 'es'; - const translatedPhrase = getTranslatedPhrase(language, phraseKey, languageAbbreviation, ...phraseParameters); + const translatedPhrase = getTranslatedPhrase(language, path, languageAbbreviation, ...parameters); if (translatedPhrase !== null && translatedPhrase !== undefined) { return translatedPhrase; } @@ -157,21 +182,21 @@ function translate(desiredLanguage: 'en' | 'es' | // Phrase is not found in default language, on production and staging log an alert to server // on development throw an error if (Config.IS_IN_PRODUCTION || Config.IS_IN_STAGING) { - const phraseString: string = Array.isArray(phraseKey) ? phraseKey.join('.') : phraseKey; + const phraseString = Array.isArray(path) ? path.join('.') : path; Log.alert(`${phraseString} was not found in the en locale`); if (userEmail.includes(CONST.EMAIL.EXPENSIFY_EMAIL_DOMAIN)) { return CONST.MISSING_TRANSLATION; } return phraseString; } - throw new Error(`${phraseKey} was not found in the default language`); + throw new Error(`${path} was not found in the default language`); } /** * Uses the locale in this file updated by the Onyx subscriber. */ -function translateLocal(phrase: TKey, ...variables: PhraseParameters>) { - return translate(BaseLocaleListener.getPreferredLocale(), phrase, ...variables); +function translateLocal(phrase: TPath, ...parameters: TranslationParameters) { + return translate(BaseLocaleListener.getPreferredLocale(), phrase, ...parameters); } function getPreferredListFormat(): Intl.ListFormat { @@ -226,4 +251,3 @@ function getDevicePreferredLocale(): Locale { } export {translate, translateLocal, formatList, formatMessageElementList, getDevicePreferredLocale}; -export type {PhraseParameters, Phrase}; diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts index 92f81f9629cb..319ec60d143e 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -1111,7 +1111,12 @@ const config: LinkingOptions['config'] = { }, [SCREENS.RIGHT_MODAL.EDIT_REQUEST]: { screens: { - [SCREENS.EDIT_REQUEST.REPORT_FIELD]: ROUTES.EDIT_REPORT_FIELD_REQUEST.route, + [SCREENS.EDIT_REQUEST.REPORT_FIELD]: { + path: ROUTES.EDIT_REPORT_FIELD_REQUEST.route, + parse: { + fieldID: (fieldID: string) => decodeURIComponent(fieldID), + }, + }, }, }, [SCREENS.RIGHT_MODAL.SIGN_IN]: { 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/Navigation/types.ts b/src/libs/Navigation/types.ts index 39cc50affaa7..39053de521db 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -68,6 +68,7 @@ type CentralPaneScreensParamList = { [SCREENS.SEARCH.CENTRAL_PANE]: { q: SearchQueryString; + name?: string; }; [SCREENS.SETTINGS.SAVE_THE_WORLD]: undefined; [SCREENS.SETTINGS.SUBSCRIPTION.ROOT]: undefined; diff --git a/src/libs/OptionsListUtils.ts b/src/libs/OptionsListUtils.ts index 2a25178f26a6..51db5a693f91 100644 --- a/src/libs/OptionsListUtils.ts +++ b/src/libs/OptionsListUtils.ts @@ -222,6 +222,7 @@ type PreviewConfig = {showChatPreviewLine?: boolean; forcePolicyNamePreview?: bo type FilterOptionsConfig = Pick & { preferChatroomsOverThreads?: boolean; preferPolicyExpenseChat?: boolean; + preferRecentExpenseReports?: boolean; }; /** @@ -659,7 +660,7 @@ function getLastMessageTextForReport(report: OnyxEntry, lastActorDetails lastMessageTextFromReport = ReportUtils.formatReportLastMessageText(TaskUtils.getTaskReportActionMessage(lastReportAction).text); } else if (ReportActionUtils.isCreatedTaskReportAction(lastReportAction)) { lastMessageTextFromReport = TaskUtils.getTaskCreatedMessage(lastReportAction); - } else if (lastReportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.SUBMITTED) { + } else if (ReportActionUtils.isActionOfType(lastReportAction, CONST.REPORT.ACTIONS.TYPE.SUBMITTED)) { lastMessageTextFromReport = ReportUtils.getIOUSubmittedMessage(lastReportAction); } else if (lastReportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.APPROVED) { lastMessageTextFromReport = ReportUtils.getIOUApprovedMessage(lastReportAction); @@ -1573,7 +1574,11 @@ function createOptionFromReport(report: Report, personalDetails: OnyxEntry option?.lastIOUCreationDate ?? '' : '', + preferRecentExpenseReports ? (option) => option?.isPolicyExpenseChat : 0, ], - ['asc'], + ['asc', 'desc', 'desc'], ); } @@ -1923,6 +1937,8 @@ function getOptions( let recentReportOptions = []; let personalDetailsOptions: ReportUtils.OptionData[] = []; + const preferRecentExpenseReports = action === CONST.IOU.ACTION.CREATE; + if (includeRecentReports) { for (const reportOption of allReportOptions) { /** @@ -1983,6 +1999,22 @@ function getOptions( recentReportOptions.push(reportOption); } + // Add a field to sort the recent reports by the time of last IOU request for create actions + if (preferRecentExpenseReports) { + const reportPreviewAction = allSortedReportActions[reportOption.reportID]?.find((reportAction) => + ReportActionUtils.isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW), + ); + + if (reportPreviewAction) { + const iouReportID = ReportActionUtils.getIOUReportIDFromReportActionPreview(reportPreviewAction); + const iouReportActions = allSortedReportActions[iouReportID] ?? []; + const lastIOUAction = iouReportActions.find((iouAction) => iouAction.actionName === CONST.REPORT.ACTIONS.TYPE.IOU); + if (lastIOUAction) { + reportOption.lastIOUCreationDate = lastIOUAction.lastModified; + } + } + } + // Add this login to the exclude list so it won't appear when we process the personal details if (reportOption.login) { optionsToExclude.push({login: reportOption.login}); @@ -2031,7 +2063,11 @@ function getOptions( recentReportOptions.push(...personalDetailsOptions); personalDetailsOptions = []; } - recentReportOptions = orderOptions(recentReportOptions, searchValue, {preferChatroomsOverThreads: true, preferPolicyExpenseChat: !!action}); + recentReportOptions = orderOptions(recentReportOptions, searchValue, { + preferChatroomsOverThreads: true, + preferPolicyExpenseChat: !!action, + preferRecentExpenseReports, + }); } return { @@ -2395,6 +2431,7 @@ function filterOptions(options: Options, searchInputValue: string, config?: Filt excludeLogins = [], preferChatroomsOverThreads = false, preferPolicyExpenseChat = false, + preferRecentExpenseReports = false, } = config ?? {}; if (searchInputValue.trim() === '' && maxRecentReportsToShow > 0) { return {...options, recentReports: options.recentReports.slice(0, maxRecentReportsToShow)}; @@ -2476,7 +2513,7 @@ function filterOptions(options: Options, searchInputValue: string, config?: Filt return { personalDetails, - recentReports: orderOptions(recentReports, searchValue, {preferChatroomsOverThreads, preferPolicyExpenseChat}), + recentReports: orderOptions(recentReports, searchValue, {preferChatroomsOverThreads, preferPolicyExpenseChat, preferRecentExpenseReports}), userToInvite, currentUserOption: matchResults.currentUserOption, categoryOptions: [], 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 4d13051d107c..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; @@ -821,7 +827,10 @@ function getCustomersOrJobsLabelNetSuite(policy: Policy | undefined, translate: importFields.push(translate('workspace.netsuite.import.customersOrJobs.jobs')); } - const importedValueLabel = translate(`workspace.netsuite.import.customersOrJobs.label`, importFields, translate(`workspace.accounting.importTypes.${importedValue}`).toLowerCase()); + const importedValueLabel = translate(`workspace.netsuite.import.customersOrJobs.label`, { + importFields, + importType: translate(`workspace.accounting.importTypes.${importedValue}`).toLowerCase(), + }); return importedValueLabel.charAt(0).toUpperCase() + importedValueLabel.slice(1); } diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts index f584f694edd0..15d7728ba35a 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -215,8 +215,10 @@ function isActionOfType( function getOriginalMessage(reportAction: OnyxInputOrEntry>): OriginalMessage | undefined { if (!Array.isArray(reportAction?.message)) { + // eslint-disable-next-line deprecation/deprecation return reportAction?.message ?? reportAction?.originalMessage; } + // eslint-disable-next-line deprecation/deprecation return reportAction.originalMessage; } @@ -593,6 +595,7 @@ function isReportActionDeprecated(reportAction: OnyxEntry, key: st // HACK ALERT: We're temporarily filtering out any reportActions keyed by sequenceNumber // to prevent bugs during the migration from sequenceNumber -> reportActionID + // eslint-disable-next-line deprecation/deprecation if (String(reportAction.sequenceNumber) === key) { Log.info('Front-end filtered out reportAction keyed by sequenceNumber!', false, reportAction); return true; @@ -753,6 +756,18 @@ function getLastVisibleAction(reportID: string, actionsToMerge: Record 0) || (trimmedMessage === '?\u2026' && lastMessageText.length > CONST.REPORT.MIN_LENGTH_LAST_MESSAGE_WITH_ELLIPSIS)) { + return ' '; + } + + return StringUtils.lineBreaksToSpaces(trimmedMessage).substring(0, CONST.REPORT.LAST_MESSAGE_TEXT_MAX_LENGTH).trim(); +} + function getLastVisibleMessage( reportID: string, actionsToMerge: Record | null> = {}, @@ -777,7 +792,7 @@ function getLastVisibleMessage( let messageText = getReportActionMessageText(lastVisibleAction) ?? ''; if (messageText) { - messageText = StringUtils.lineBreaksToSpaces(String(messageText)).substring(0, CONST.REPORT.LAST_MESSAGE_TEXT_MAX_LENGTH).trim(); + messageText = formatLastMessageText(messageText); } return { lastMessageText: messageText, @@ -994,20 +1009,20 @@ const iouRequestTypes = new Set>([ CONST.IOU.REPORT_ACTION_TYPE.TRACK, ]); -/** - * Gets the reportID for the transaction thread associated with a report by iterating over the reportActions and identifying the IOU report actions. - * Returns a reportID if there is exactly one transaction thread for the report, and null otherwise. - */ -function getOneTransactionThreadReportID(reportID: string, reportActions: OnyxEntry | ReportAction[], isOffline: boolean | undefined = undefined): string | undefined { - // If the report is not an IOU, Expense report, or Invoice, it shouldn't be treated as one-transaction report. +function getMoneyRequestActions( + reportID: string, + reportActions: OnyxEntry | ReportAction[], + isOffline: boolean | undefined = undefined, +): Array> { + // If the report is not an IOU, Expense report, or Invoice, it shouldn't have money request actions. const report = ReportConnection.getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; if (report?.type !== CONST.REPORT.TYPE.IOU && report?.type !== CONST.REPORT.TYPE.EXPENSE && report?.type !== CONST.REPORT.TYPE.INVOICE) { - return; + return []; } const reportActionsArray = Array.isArray(reportActions) ? reportActions : Object.values(reportActions ?? {}); if (!reportActionsArray.length) { - return; + return []; } const iouRequestActions = []; @@ -1035,6 +1050,15 @@ function getOneTransactionThreadReportID(reportID: string, reportActions: OnyxEn iouRequestActions.push(action); } } + return iouRequestActions; +} + +/** + * Gets the reportID for the transaction thread associated with a report by iterating over the reportActions and identifying the IOU report actions. + * Returns a reportID if there is exactly one transaction thread for the report, and null otherwise. + */ +function getOneTransactionThreadReportID(reportID: string, reportActions: OnyxEntry | ReportAction[], isOffline: boolean | undefined = undefined): string | undefined { + const iouRequestActions = getMoneyRequestActions(reportID, reportActions, isOffline); // If we don't have any IOU request actions, or we have more than one IOU request actions, this isn't a oneTransaction report if (!iouRequestActions.length || iouRequestActions.length > 1) { @@ -1054,6 +1078,27 @@ function getOneTransactionThreadReportID(reportID: string, reportActions: OnyxEn return singleAction.childReportID; } +/** + * Returns true if all transactions on the report have the same ownerID + */ +function hasSameActorForAllTransactions(reportID: string, reportActions: OnyxEntry | ReportAction[], isOffline: boolean | undefined = undefined): boolean { + const iouRequestActions = getMoneyRequestActions(reportID, reportActions, isOffline); + if (!iouRequestActions.length) { + return true; + } + + let actorID: number | undefined; + + for (const action of iouRequestActions) { + if (actorID !== undefined && actorID !== action?.actorAccountID) { + return false; + } + actorID = action?.actorAccountID; + } + + return true; +} + /** * When we delete certain reports, we want to check whether there are any visible actions left to display. * If there are no visible actions left (including system messages), we can hide the report from view entirely @@ -1266,7 +1311,7 @@ function getMessageOfOldDotReportAction(oldDotAction: PartialReportAction | OldD case CONST.REPORT.ACTIONS.TYPE.INTEGRATIONS_MESSAGE: { const {result, label} = originalMessage; const errorMessage = result?.messages?.join(', ') ?? ''; - return Localize.translateLocal('report.actions.type.integrationsMessage', errorMessage, label); + return Localize.translateLocal('report.actions.type.integrationsMessage', {errorMessage, label}); } case CONST.REPORT.ACTIONS.TYPE.MANAGER_ATTACH_RECEIPT: return Localize.translateLocal('report.actions.type.managerAttachReceipt'); @@ -1643,7 +1688,7 @@ function getPolicyChangeLogAddEmployeeMessage(reportAction: OnyxInputOrEntry): reportAction is ReportAction { @@ -1658,7 +1703,7 @@ function getPolicyChangeLogChangeRoleMessage(reportAction: OnyxInputOrEntry>) { @@ -1690,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) { @@ -1698,7 +1743,7 @@ function isCardIssuedAction(reportAction: OnyxEntry) { } function getCardIssuedMessage(reportAction: OnyxEntry, shouldRenderHTML = false) { - const assigneeAccountID = (reportAction?.originalMessage as IssueNewCardOriginalMessage)?.assigneeAccountID; + const assigneeAccountID = (getOriginalMessage(reportAction) as IssueNewCardOriginalMessage)?.assigneeAccountID; const assigneeDetails = PersonalDetailsUtils.getPersonalDetailsByIDs([assigneeAccountID], currentUserAccountID ?? -1)[0]; const assignee = shouldRenderHTML ? `` : assigneeDetails?.firstName ?? assigneeDetails.login ?? ''; @@ -1719,11 +1764,11 @@ function getCardIssuedMessage(reportAction: OnyxEntry, shouldRende const shouldShowAddMissingDetailsButton = reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.CARD_MISSING_ADDRESS && missingDetails && isAssigneeCurrentUser; switch (reportAction?.actionName) { case CONST.REPORT.ACTIONS.TYPE.CARD_ISSUED: - return Localize.translateLocal('workspace.expensifyCard.issuedCard', assignee); + return Localize.translateLocal('workspace.expensifyCard.issuedCard', {assignee}); case CONST.REPORT.ACTIONS.TYPE.CARD_ISSUED_VIRTUAL: return Localize.translateLocal('workspace.expensifyCard.issuedCardVirtual', {assignee, link}); case CONST.REPORT.ACTIONS.TYPE.CARD_MISSING_ADDRESS: - return Localize.translateLocal(`workspace.expensifyCard.${shouldShowAddMissingDetailsButton ? 'issuedCardNoShippingDetails' : 'addedShippingDetails'}`, assignee); + return Localize.translateLocal(`workspace.expensifyCard.${shouldShowAddMissingDetailsButton ? 'issuedCardNoShippingDetails' : 'addedShippingDetails'}`, {assignee}); default: return ''; } @@ -1732,6 +1777,7 @@ function getCardIssuedMessage(reportAction: OnyxEntry, shouldRende export { doesReportHaveVisibleActions, extractLinksFromMessageHtml, + formatLastMessageText, getActionableMentionWhisperMessage, getAllReportActions, getCombinedReportActions, @@ -1754,6 +1800,7 @@ export { getNumberOfMoneyRequests, getOneTransactionThreadReportID, getOriginalMessage, + // eslint-disable-next-line deprecation/deprecation getParentReportAction, getRemovedFromApprovalChainMessage, getReportAction, @@ -1834,6 +1881,7 @@ export { getRenamedAction, isCardIssuedAction, getCardIssuedMessage, + hasSameActorForAllTransactions, }; export type {LastVisibleMessage}; diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 1c52c316ea10..78ebdd92751e 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -19,7 +19,8 @@ import * as defaultWorkspaceAvatars from '@components/Icon/WorkspaceDefaultAvata import type {MoneyRequestAmountInputProps} from '@components/MoneyRequestAmountInput'; import type {IOUAction, IOUType} from '@src/CONST'; import CONST from '@src/CONST'; -import type {ParentNavigationSummaryParams, TranslationPaths} from '@src/languages/types'; +import type {ParentNavigationSummaryParams} from '@src/languages/params'; +import type {TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Route} from '@src/ROUTES'; import ROUTES from '@src/ROUTES'; @@ -83,7 +84,6 @@ import * as PolicyUtils from './PolicyUtils'; import type {LastVisibleMessage} from './ReportActionsUtils'; import * as ReportActionsUtils from './ReportActionsUtils'; import * as ReportConnection from './ReportConnection'; -import StringUtils from './StringUtils'; import * as TransactionUtils from './TransactionUtils'; import * as Url from './Url'; import type {AvatarSource} from './UserUtils'; @@ -127,6 +127,7 @@ type OptimisticAddCommentReportAction = Pick< | 'childCommenterCount' | 'childLastVisibleActionCreated' | 'childOldestFourAccountIDs' + | 'delegateAccountID' > & {isOptimisticAction: boolean}; type OptimisticReportAction = { @@ -180,6 +181,7 @@ type OptimisticIOUReportAction = Pick< | 'childReportID' | 'childVisibleActionCount' | 'childCommenterCount' + | 'delegateAccountID' >; type PartialReportAction = OnyxInputOrEntry | Partial | OptimisticIOUReportAction | OptimisticApprovedReportAction | OptimisticSubmittedReportAction | undefined; @@ -196,12 +198,36 @@ type ReportOfflinePendingActionAndErrors = { type OptimisticApprovedReportAction = Pick< ReportAction, - 'actionName' | 'actorAccountID' | 'automatic' | 'avatar' | 'isAttachmentOnly' | 'originalMessage' | 'message' | 'person' | 'reportActionID' | 'shouldShow' | 'created' | 'pendingAction' + | 'actionName' + | 'actorAccountID' + | 'automatic' + | 'avatar' + | 'isAttachmentOnly' + | 'originalMessage' + | 'message' + | 'person' + | 'reportActionID' + | 'shouldShow' + | 'created' + | 'pendingAction' + | 'delegateAccountID' >; type OptimisticUnapprovedReportAction = Pick< ReportAction, - 'actionName' | 'actorAccountID' | 'automatic' | 'avatar' | 'isAttachmentOnly' | 'originalMessage' | 'message' | 'person' | 'reportActionID' | 'shouldShow' | 'created' | 'pendingAction' + | 'actionName' + | 'actorAccountID' + | 'automatic' + | 'avatar' + | 'isAttachmentOnly' + | 'originalMessage' + | 'message' + | 'person' + | 'reportActionID' + | 'shouldShow' + | 'created' + | 'pendingAction' + | 'delegateAccountID' >; type OptimisticSubmittedReportAction = Pick< @@ -219,6 +245,7 @@ type OptimisticSubmittedReportAction = Pick< | 'shouldShow' | 'created' | 'pendingAction' + | 'delegateAccountID' >; type OptimisticHoldReportAction = Pick< @@ -233,7 +260,7 @@ type OptimisticCancelPaymentReportAction = Pick< type OptimisticEditedTaskReportAction = Pick< ReportAction, - 'reportActionID' | 'actionName' | 'pendingAction' | 'actorAccountID' | 'automatic' | 'avatar' | 'created' | 'shouldShow' | 'message' | 'person' + 'reportActionID' | 'actionName' | 'pendingAction' | 'actorAccountID' | 'automatic' | 'avatar' | 'created' | 'shouldShow' | 'message' | 'person' | 'delegateAccountID' >; type OptimisticClosedReportAction = Pick< @@ -248,7 +275,7 @@ type OptimisticDismissedViolationReportAction = Pick< type OptimisticCreatedReportAction = Pick< ReportAction, - 'actorAccountID' | 'automatic' | 'avatar' | 'created' | 'message' | 'person' | 'reportActionID' | 'shouldShow' | 'pendingAction' | 'actionName' + 'actorAccountID' | 'automatic' | 'avatar' | 'created' | 'message' | 'person' | 'reportActionID' | 'shouldShow' | 'pendingAction' | 'actionName' | 'delegateAccountID' >; type OptimisticRenamedReportAction = Pick< @@ -321,13 +348,22 @@ type OptimisticTaskReportAction = Pick< | 'previousMessage' | 'errors' | 'linkMetadata' + | '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; @@ -340,7 +376,19 @@ type OptimisticWorkspaceChats = { type OptimisticModifiedExpenseReportAction = Pick< ReportAction, - 'actionName' | 'actorAccountID' | 'automatic' | 'avatar' | 'created' | 'isAttachmentOnly' | 'message' | 'originalMessage' | 'person' | 'pendingAction' | 'reportActionID' | 'shouldShow' + | 'actionName' + | 'actorAccountID' + | 'automatic' + | 'avatar' + | 'created' + | 'isAttachmentOnly' + | 'message' + | 'originalMessage' + | 'person' + | 'pendingAction' + | 'reportActionID' + | 'shouldShow' + | 'delegateAccountID' > & {reportID?: string}; type OptimisticTaskReport = Pick< @@ -459,6 +507,7 @@ type OptionData = { tabIndex?: 0 | -1; isConciergeChat?: boolean; isBold?: boolean; + lastIOUCreationDate?: string; } & Report; type OnyxDataTaskAssigneeChat = { @@ -636,6 +685,14 @@ Onyx.connect({ callback: (value) => (onboarding = value), }); +let delegateEmail = ''; +Onyx.connect({ + key: ONYXKEYS.ACCOUNT, + callback: (value) => { + delegateEmail = value?.delegatedAccess?.delegate ?? ''; + }, +}); + function getCurrentUserAvatar(): AvatarSource | undefined { return currentUserPersonalDetails?.avatar; } @@ -1580,12 +1637,20 @@ function hasOnlyNonReimbursableTransactions(iouReportID: string | undefined): bo return transactions.every((transaction) => !TransactionUtils.getReimbursable(transaction)); } +/** + * Checks if a report has only transactions with same ownerID + */ +function isSingleActorMoneyReport(reportID: string): boolean { + const reportActions = allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`] ?? ([] as ReportAction[]); + return !!ReportActionsUtils.hasSameActorForAllTransactions(reportID, reportActions); +} + /** * Checks if a report has only one transaction associated with it */ function isOneTransactionReport(reportID: string): boolean { const reportActions = allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`] ?? ([] as ReportAction[]); - return ReportActionsUtils.getOneTransactionThreadReportID(reportID, reportActions) !== null; + return !!ReportActionsUtils.getOneTransactionThreadReportID(reportID, reportActions); } /* @@ -1830,7 +1895,8 @@ function formatReportLastMessageText(lastMessageText: string, isModifiedExpenseM if (isModifiedExpenseMessage) { return String(lastMessageText).trim().replace(CONST.REGEX.LINE_BREAK, '').trim(); } - return StringUtils.lineBreaksToSpaces(String(lastMessageText).trim()).substring(0, CONST.REPORT.LAST_MESSAGE_TEXT_MAX_LENGTH).trim(); + + return ReportActionsUtils.formatLastMessageText(lastMessageText); } /** @@ -1939,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); @@ -2217,7 +2283,7 @@ function getIcons( if (isChatThread(report)) { const parentReportAction = allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.parentReportID}`]?.[report.parentReportActionID]; - const actorAccountID = getReportActionActorAccountID(parentReportAction, report); + const actorAccountID = getReportActionActorAccountID(parentReportAction); const actorDisplayName = PersonalDetailsUtils.getDisplayNameOrDefault(allPersonalDetails?.[actorAccountID ?? -1], '', false); const actorIcon = { id: actorAccountID, @@ -2312,7 +2378,7 @@ function getIcons( const isManager = currentUserAccountID === report?.managerID; // For one transaction IOUs, display a simplified report icon - if (isOneTransactionReport(report?.reportID ?? '-1')) { + if (isOneTransactionReport(report?.reportID ?? '-1') || isSingleActorMoneyReport(report?.reportID ?? '-1')) { return [ownerIcon]; } @@ -3127,7 +3193,7 @@ function canHoldUnholdReportAction(reportAction: OnyxInputOrEntry) return {canHoldRequest, canUnholdRequest}; } -const changeMoneyRequestHoldStatus = (reportAction: OnyxEntry, backTo?: string): void => { +const changeMoneyRequestHoldStatus = (reportAction: OnyxEntry, backTo?: string, searchHash?: number): void => { if (!ReportActionsUtils.isMoneyRequestAction(reportAction)) { return; } @@ -3144,11 +3210,13 @@ const changeMoneyRequestHoldStatus = (reportAction: OnyxEntry, bac const policy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${moneyRequestReport.policyID}`] ?? null; if (isOnHold) { - IOU.unholdRequest(transactionID, reportAction.childReportID ?? ''); + IOU.unholdRequest(transactionID, reportAction.childReportID ?? '', searchHash); } else { const activeRoute = encodeURIComponent(Navigation.getActiveRouteWithoutParams()); - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - Navigation.navigate(ROUTES.MONEY_REQUEST_HOLD_REASON.getRoute(policy?.type ?? CONST.POLICY.TYPE.PERSONAL, transactionID, reportAction.childReportID ?? '', backTo || activeRoute)); + Navigation.navigate( + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + ROUTES.MONEY_REQUEST_HOLD_REASON.getRoute(policy?.type ?? CONST.POLICY.TYPE.PERSONAL, transactionID, reportAction.childReportID ?? '', backTo || activeRoute, searchHash), + ); } }; @@ -3713,7 +3781,7 @@ function getReportName( } const parentReportActionMessage = ReportActionsUtils.getReportActionMessage(parentReportAction); - if (parentReportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.SUBMITTED) { + if (ReportActionsUtils.isActionOfType(parentReportAction, CONST.REPORT.ACTIONS.TYPE.SUBMITTED)) { return getIOUSubmittedMessage(parentReportAction); } if (parentReportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.FORWARDED) { @@ -4137,6 +4205,7 @@ function buildOptimisticAddCommentReportAction( const isAttachmentOnly = file && !text; const isAttachmentWithText = !!text && file !== undefined; const accountID = actorAccountID ?? currentUserAccountID ?? -1; + const delegateAccountDetails = PersonalDetailsUtils.getPersonalDetailByEmail(delegateEmail); // Remove HTML from text when applying optimistic offline comment return { @@ -4173,6 +4242,7 @@ function buildOptimisticAddCommentReportAction( pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, shouldShow: true, isOptimisticAction: true, + delegateAccountID: delegateAccountDetails?.accountID, }, }; } @@ -4448,7 +4518,11 @@ function getFormattedAmount(reportAction: ReportAction) { return formattedAmount; } -function getIOUSubmittedMessage(reportAction: ReportAction) { +function getReportAutomaticallySubmittedMessage(reportAction: ReportAction) { + return Localize.translateLocal('iou.automaticallySubmittedAmount', {formattedAmount: getFormattedAmount(reportAction)}); +} + +function getIOUSubmittedMessage(reportAction: ReportAction) { return Localize.translateLocal('iou.submittedAmount', {formattedAmount: getFormattedAmount(reportAction)}); } @@ -4597,6 +4671,8 @@ function buildOptimisticIOUReportAction( type, }; + const delegateAccountDetails = PersonalDetailsUtils.getPersonalDetailByEmail(delegateEmail); + if (type === CONST.IOU.REPORT_ACTION_TYPE.PAY) { // In pay someone flow, we store amount, comment, currency in IOUDetails when type = pay if (isSendMoneyFlow) { @@ -4648,6 +4724,7 @@ function buildOptimisticIOUReportAction( shouldShow: true, created, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + delegateAccountID: delegateAccountDetails?.accountID, }; } @@ -4660,6 +4737,7 @@ function buildOptimisticApprovedReportAction(amount: number, currency: string, e currency, expenseReportID, }; + const delegateAccountDetails = PersonalDetailsUtils.getPersonalDetailByEmail(delegateEmail); return { actionName: CONST.REPORT.ACTIONS.TYPE.APPROVED, @@ -4680,6 +4758,7 @@ function buildOptimisticApprovedReportAction(amount: number, currency: string, e shouldShow: true, created: DateUtils.getDBTime(), pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + delegateAccountID: delegateAccountDetails?.accountID, }; } @@ -4687,6 +4766,7 @@ function buildOptimisticApprovedReportAction(amount: number, currency: string, e * Builds an optimistic APPROVED report action with a randomly generated reportActionID. */ function buildOptimisticUnapprovedReportAction(amount: number, currency: string, expenseReportID: string): OptimisticUnapprovedReportAction { + const delegateAccountDetails = PersonalDetailsUtils.getPersonalDetailByEmail(delegateEmail); return { actionName: CONST.REPORT.ACTIONS.TYPE.UNAPPROVED, actorAccountID: currentUserAccountID, @@ -4710,6 +4790,7 @@ function buildOptimisticUnapprovedReportAction(amount: number, currency: string, shouldShow: true, created: DateUtils.getDBTime(), pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + delegateAccountID: delegateAccountDetails?.accountID, }; } @@ -4766,6 +4847,8 @@ function buildOptimisticSubmittedReportAction(amount: number, currency: string, expenseReportID, }; + const delegateAccountDetails = PersonalDetailsUtils.getPersonalDetailByEmail(delegateEmail); + return { actionName: CONST.REPORT.ACTIONS.TYPE.SUBMITTED, actorAccountID: currentUserAccountID, @@ -4786,6 +4869,7 @@ function buildOptimisticSubmittedReportAction(amount: number, currency: string, shouldShow: true, created: DateUtils.getDBTime(), pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + delegateAccountID: delegateAccountDetails?.accountID, }; } @@ -4826,9 +4910,9 @@ function buildOptimisticReportPreview( }, ], created, - accountID: iouReport?.managerID ?? -1, + accountID: iouReport?.ownerAccountID ?? -1, // The preview is initially whispered if created with a receipt, so the actor is the current user as well - actorAccountID: hasReceipt ? currentUserAccountID : iouReport?.managerID ?? -1, + actorAccountID: hasReceipt ? currentUserAccountID : iouReport?.ownerAccountID ?? -1, childReportID: childReportID ?? iouReport?.reportID, childMoneyRequestCount: 1, childLastMoneyRequestComment: comment, @@ -4886,6 +4970,8 @@ function buildOptimisticModifiedExpenseReportAction( updatedTransaction?: OnyxInputOrEntry, ): OptimisticModifiedExpenseReportAction { const originalMessage = getModifiedExpenseOriginalMessage(oldTransaction, transactionChanges, isFromExpenseReport, policy, updatedTransaction); + const delegateAccountDetails = PersonalDetailsUtils.getPersonalDetailByEmail(delegateEmail); + return { actionName: CONST.REPORT.ACTIONS.TYPE.MODIFIED_EXPENSE, actorAccountID: currentUserAccountID, @@ -4913,6 +4999,7 @@ function buildOptimisticModifiedExpenseReportAction( reportActionID: NumberUtils.rand64(), reportID: transactionThread?.reportID, shouldShow: true, + delegateAccountID: delegateAccountDetails?.accountID, }; } @@ -4922,6 +5009,8 @@ function buildOptimisticModifiedExpenseReportAction( * @param movedToReportID - The reportID of the report the transaction is moved to */ function buildOptimisticMovedTrackedExpenseModifiedReportAction(transactionThreadID: string, movedToReportID: string): OptimisticModifiedExpenseReportAction { + const delegateAccountDetails = PersonalDetailsUtils.getPersonalDetailByEmail(delegateEmail); + return { actionName: CONST.REPORT.ACTIONS.TYPE.MODIFIED_EXPENSE, actorAccountID: currentUserAccountID, @@ -4951,6 +5040,7 @@ function buildOptimisticMovedTrackedExpenseModifiedReportAction(transactionThrea reportActionID: NumberUtils.rand64(), reportID: transactionThreadID, shouldShow: true, + delegateAccountID: delegateAccountDetails?.accountID, }; } @@ -5026,6 +5116,8 @@ function buildOptimisticTaskReportAction( html: message, whisperedTo: [], }; + const delegateAccountDetails = PersonalDetailsUtils.getPersonalDetailByEmail(delegateEmail); + return { actionName, actorAccountID, @@ -5052,6 +5144,7 @@ function buildOptimisticTaskReportAction( created: DateUtils.getDBTimeWithSkew(Date.now() + createdOffset), isFirstItem: false, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + delegateAccountID: delegateAccountDetails?.accountID, }; } @@ -5381,6 +5474,7 @@ function buildOptimisticEditedTaskFieldReportAction({title, description}: Task): } else if (field) { changelog = `removed the ${field}`; } + const delegateAccountDetails = PersonalDetailsUtils.getPersonalDetailByEmail(delegateEmail); return { reportActionID: NumberUtils.rand64(), @@ -5405,10 +5499,13 @@ function buildOptimisticEditedTaskFieldReportAction({title, description}: Task): avatar: getCurrentUserAvatar(), created: DateUtils.getDBTime(), shouldShow: false, + delegateAccountID: delegateAccountDetails?.accountID, }; } function buildOptimisticChangedTaskAssigneeReportAction(assigneeAccountID: number): OptimisticEditedTaskReportAction { + const delegateAccountDetails = PersonalDetailsUtils.getPersonalDetailByEmail(delegateEmail); + return { reportActionID: NumberUtils.rand64(), actionName: CONST.REPORT.ACTIONS.TYPE.TASK_EDITED, @@ -5432,6 +5529,7 @@ function buildOptimisticChangedTaskAssigneeReportAction(assigneeAccountID: numbe avatar: getCurrentUserAvatar(), created: DateUtils.getDBTime(), shouldShow: false, + delegateAccountID: delegateAccountDetails?.accountID, }; } @@ -5513,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( @@ -5575,10 +5762,6 @@ function buildOptimisticWorkspaceChats(policyID: string, policyName: string, exp }; return { - announceChatReportID, - announceChatData, - announceReportActionData, - announceCreatedReportActionID: announceCreatedAction.reportActionID, adminsChatReportID, adminsChatData, adminsReportActionData, @@ -6649,7 +6832,7 @@ function shouldReportShowSubscript(report: OnyxEntry): boolean { return true; } - if (isExpenseReport(report) && isOneTransactionReport(report?.reportID ?? '-1')) { + if (isExpenseReport(report)) { return true; } @@ -6661,7 +6844,7 @@ function shouldReportShowSubscript(report: OnyxEntry): boolean { return true; } - if (isInvoiceRoom(report)) { + if (isInvoiceRoom(report) || isInvoiceReport(report)) { return true; } @@ -7543,10 +7726,10 @@ function canLeaveChat(report: OnyxEntry, policy: OnyxEntry): boo return (isChatThread(report) && !!getReportNotificationPreference(report)) || isUserCreatedPolicyRoom(report) || isNonAdminOrOwnerOfPolicyExpenseChat(report, policy); } -function getReportActionActorAccountID(reportAction: OnyxInputOrEntry, iouReport: OnyxInputOrEntry | undefined): number | undefined { +function getReportActionActorAccountID(reportAction: OnyxInputOrEntry): number | undefined { switch (reportAction?.actionName) { case CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW: - return !isEmptyObject(iouReport) ? iouReport.managerID : reportAction?.childManagerAccountID; + return reportAction?.childOwnerAccountID ?? reportAction?.actorAccountID; case CONST.REPORT.ACTIONS.TYPE.SUBMITTED: return reportAction?.adminAccountID ?? reportAction?.actorAccountID; @@ -7701,7 +7884,7 @@ function getFieldViolationTranslation(reportField: PolicyReportField, violation? switch (violation) { case 'fieldRequired': - return Localize.translateLocal('reportViolations.fieldRequired', reportField.name); + return Localize.translateLocal('reportViolations.fieldRequired', {fieldName: reportField.name}); default: return ''; } @@ -7815,8 +7998,8 @@ function hasMissingInvoiceBankAccount(iouReportID: string): boolean { return invoiceReport?.ownerAccountID === currentUserAccountID && isEmptyObject(getPolicy(invoiceReport?.policyID)?.invoice?.bankAccount ?? {}) && isSettled(iouReportID); } -function isExpenseReportManagerWithoutParentAccess(report: OnyxEntry) { - return isExpenseReport(report) && report?.hasParentAccess === false && report?.managerID === currentUserAccountID; +function isExpenseReportWithoutParentAccess(report: OnyxEntry) { + return isExpenseReport(report) && report?.hasParentAccess === false; } export { @@ -7852,6 +8035,7 @@ export { buildOptimisticTaskReport, buildOptimisticTaskReportAction, buildOptimisticUnHoldReportAction, + buildOptimisticAnnounceChat, buildOptimisticWorkspaceChats, buildParticipantsFromAccountIDs, buildTransactionThread, @@ -7917,6 +8101,7 @@ export { getIOUForwardedMessage, getRejectedReportMessage, getWorkspaceNameUpdatedMessage, + getReportAutomaticallySubmittedMessage, getIOUSubmittedMessage, getIcons, getIconsForParticipants, @@ -8014,7 +8199,7 @@ export { isEmptyReport, isRootGroupChat, isExpenseReport, - isExpenseReportManagerWithoutParentAccess, + isExpenseReportWithoutParentAccess, isExpenseRequest, isExpensifyOnlyParticipantInReport, isGroupChat, @@ -8127,6 +8312,7 @@ export { isIndividualInvoiceRoom, isAuditor, hasMissingInvoiceBankAccount, + isSingleActorMoneyReport, }; export type { diff --git a/src/libs/SearchUtils.ts b/src/libs/SearchUtils.ts index e178c7dcb77b..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) { @@ -784,7 +793,7 @@ function getOverflowMenu(itemName: string, hash: number, inputQuery: string, sho if (isMobileMenu && closeMenu) { closeMenu(); } - Navigation.navigate(ROUTES.SEARCH_SAVED_SEARCH_RENAME.getRoute({name: itemName, jsonQuery: inputQuery})); + Navigation.navigate(ROUTES.SEARCH_SAVED_SEARCH_RENAME.getRoute({name: encodeURIComponent(itemName), jsonQuery: inputQuery})); }, icon: Expensicons.Pencil, shouldShowRightIcon: false, diff --git a/src/libs/SidebarUtils.ts b/src/libs/SidebarUtils.ts index d056f111695e..e120f7026fce 100644 --- a/src/libs/SidebarUtils.ts +++ b/src/libs/SidebarUtils.ts @@ -129,10 +129,10 @@ function getOrderedReportIDs( return; } const isSystemChat = ReportUtils.isSystemChat(report); - const isExpenseReportManagerWithoutParentAccess = ReportUtils.isExpenseReportManagerWithoutParentAccess(report); + const isExpenseReportWithoutParentAccess = ReportUtils.isExpenseReportWithoutParentAccess(report); const shouldOverrideHidden = // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - hasValidDraftComment(report.reportID) || hasErrorsOtherThanFailedReceipt || isFocused || isSystemChat || report.isPinned || isExpenseReportManagerWithoutParentAccess; + hasValidDraftComment(report.reportID) || hasErrorsOtherThanFailedReceipt || isFocused || isSystemChat || report.isPinned || isExpenseReportWithoutParentAccess; if (isHidden && !shouldOverrideHidden) { return; } diff --git a/src/libs/Violations/ViolationsUtils.ts b/src/libs/Violations/ViolationsUtils.ts index adbc05460220..c7ee0a0b0867 100644 --- a/src/libs/Violations/ViolationsUtils.ts +++ b/src/libs/Violations/ViolationsUtils.ts @@ -1,11 +1,10 @@ import reject from 'lodash/reject'; import Onyx from 'react-native-onyx'; import type {OnyxUpdate} from 'react-native-onyx'; -import type {Phrase, PhraseParameters} from '@libs/Localize'; +import type {LocaleContextProps} from '@components/LocaleContextProvider'; import {getCustomUnitRate, getSortedTagKeys} from '@libs/PolicyUtils'; import * as TransactionUtils from '@libs/TransactionUtils'; import CONST from '@src/CONST'; -import type {TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Policy, PolicyCategories, PolicyTagLists, Transaction, TransactionViolation, ViolationName} from '@src/types/onyx'; @@ -237,10 +236,7 @@ const ViolationsUtils = { * possible values could be either translation keys that resolve to strings or translation keys that resolve to * functions. */ - getViolationTranslation( - violation: TransactionViolation, - translate: (phraseKey: TKey, ...phraseParameters: PhraseParameters>) => string, - ): string { + getViolationTranslation(violation: TransactionViolation, translate: LocaleContextProps['translate']): string { const { brokenBankConnection = false, isAdmin = false, @@ -250,7 +246,7 @@ const ViolationsUtils = { category, rejectedBy = '', rejectReason = '', - formattedLimit, + formattedLimit = '', surcharge = 0, invoiceMarkup = 0, maxAge = 0, diff --git a/src/libs/WorkspacesSettingsUtils.ts b/src/libs/WorkspacesSettingsUtils.ts index ed46b0b5f5ec..d8cd2ff00828 100644 --- a/src/libs/WorkspacesSettingsUtils.ts +++ b/src/libs/WorkspacesSettingsUtils.ts @@ -1,6 +1,7 @@ import Onyx from 'react-native-onyx'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; +import type {LocaleContextProps} from '@components/LocaleContextProvider'; import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -8,7 +9,6 @@ import type {Policy, ReimbursementAccount, Report, ReportAction, ReportActions, import type {PolicyConnectionSyncProgress, Unit} from '@src/types/onyx/Policy'; import {isConnectionInProgress} from './actions/connections'; import * as CurrencyUtils from './CurrencyUtils'; -import type {Phrase, PhraseParameters} from './Localize'; import * as OptionsListUtils from './OptionsListUtils'; import {hasCustomUnitsError, hasEmployeeListError, hasPolicyError, hasSyncError, hasTaxRateError} from './PolicyUtils'; import * as ReportActionsUtils from './ReportActionsUtils'; @@ -234,7 +234,7 @@ function getUnitTranslationKey(unit: Unit): TranslationPaths { */ function getOwnershipChecksDisplayText( error: ValueOf, - translate: (phraseKey: TKey, ...phraseParameters: PhraseParameters>) => string, + translate: LocaleContextProps['translate'], policy: OnyxEntry, accountLogin: string | undefined, ) { @@ -271,14 +271,14 @@ function getOwnershipChecksDisplayText( case CONST.POLICY.OWNERSHIP_ERRORS.DUPLICATE_SUBSCRIPTION: title = translate('workspace.changeOwner.duplicateSubscriptionTitle'); text = translate('workspace.changeOwner.duplicateSubscriptionText', { - email: changeOwner?.duplicateSubscription, - workspaceName: policy?.name, + email: changeOwner?.duplicateSubscription ?? '', + workspaceName: policy?.name ?? '', }); buttonText = translate('workspace.changeOwner.duplicateSubscriptionButtonText'); break; case CONST.POLICY.OWNERSHIP_ERRORS.HAS_FAILED_SETTLEMENTS: title = translate('workspace.changeOwner.hasFailedSettlementsTitle'); - text = translate('workspace.changeOwner.hasFailedSettlementsText', {email: accountLogin}); + text = translate('workspace.changeOwner.hasFailedSettlementsText', {email: accountLogin ?? ''}); buttonText = translate('workspace.changeOwner.hasFailedSettlementsButtonText'); break; case CONST.POLICY.OWNERSHIP_ERRORS.FAILED_TO_CLEAR_BALANCE: diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts index c422313a1946..5262cc4dc4ff 100644 --- a/src/libs/actions/IOU.ts +++ b/src/libs/actions/IOU.ts @@ -65,6 +65,7 @@ import type {ErrorFields, Errors} from '@src/types/onyx/OnyxCommon'; import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage'; import type ReportAction from '@src/types/onyx/ReportAction'; import type {OnyxData} from '@src/types/onyx/Request'; +import type {SearchTransaction} from '@src/types/onyx/SearchResults'; import type {Comment, Receipt, ReceiptSource, Routes, SplitShares, TransactionChanges, WaypointCollection} from '@src/types/onyx/Transaction'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import * as CachedPDFPaths from './CachedPDFPaths'; @@ -3397,8 +3398,6 @@ function categorizeTrackedExpense( receipt, policyExpenseChatReportID: createdWorkspaceParams?.expenseChatReportID, policyExpenseCreatedReportActionID: createdWorkspaceParams?.expenseCreatedReportActionID, - announceChatReportID: createdWorkspaceParams?.announceChatReportID, - announceCreatedReportActionID: createdWorkspaceParams?.announceCreatedReportActionID, adminsChatReportID: createdWorkspaceParams?.adminsChatReportID, adminsCreatedReportActionID: createdWorkspaceParams?.adminsCreatedReportActionID, }; @@ -3474,8 +3473,6 @@ function shareTrackedExpense( receipt, policyExpenseChatReportID: createdWorkspaceParams?.expenseChatReportID, policyExpenseCreatedReportActionID: createdWorkspaceParams?.expenseCreatedReportActionID, - announceChatReportID: createdWorkspaceParams?.announceChatReportID, - announceCreatedReportActionID: createdWorkspaceParams?.announceCreatedReportActionID, adminsChatReportID: createdWorkspaceParams?.adminsChatReportID, adminsCreatedReportActionID: createdWorkspaceParams?.adminsCreatedReportActionID, }; @@ -6642,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, @@ -7580,8 +7564,6 @@ function payInvoice(paymentMethodType: PaymentMethodType, chatReport: OnyxTypes. params: { reportActionID, policyID, - announceChatReportID, - announceCreatedReportActionID, adminsChatReportID, adminsCreatedReportActionID, expenseChatReportID, @@ -7607,8 +7589,6 @@ function payInvoice(paymentMethodType: PaymentMethodType, chatReport: OnyxTypes. params = { ...params, policyID, - announceChatReportID, - announceCreatedReportActionID, adminsChatReportID, adminsCreatedReportActionID, expenseChatReportID, @@ -7856,7 +7836,7 @@ function adjustRemainingSplitShares(transaction: NonNullable>>, + }); + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.SNAPSHOT}${searchHash}`, + value: { + data: { + [`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]: { + canHold: true, + canUnhold: false, + }, + }, + } as Record>>, + }); + } + API.write( 'HoldRequest', { @@ -7928,7 +7936,7 @@ function putOnHold(transactionID: string, comment: string, reportID: string) { /** * Remove expense from HOLD */ -function unholdRequest(transactionID: string, reportID: string) { +function unholdRequest(transactionID: string, reportID: string, searchHash?: number) { const createdReportAction = ReportUtils.buildOptimisticUnHoldReportAction(); const transactionViolations = allTransactionViolations[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`]; @@ -7986,6 +7994,34 @@ function unholdRequest(transactionID: string, reportID: string) { }, ]; + // If we are unholding from the search page, we optimistically update the snapshot data that search uses so that it is kept in sync + if (searchHash) { + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.SNAPSHOT}${searchHash}`, + value: { + data: { + [`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]: { + canHold: true, + canUnhold: false, + }, + }, + } as Record>>, + }); + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.SNAPSHOT}${searchHash}`, + value: { + data: { + [`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]: { + canHold: false, + canUnhold: true, + }, + }, + } as Record>>, + }); + } + API.write( 'UnHoldRequest', { 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/Category.ts b/src/libs/actions/Policy/Category.ts index c342fe6eedb6..a9f6e376b80a 100644 --- a/src/libs/actions/Policy/Category.ts +++ b/src/libs/actions/Policy/Category.ts @@ -153,7 +153,7 @@ function updateImportSpreadsheetData(categoriesLength: number) { shouldFinalModalBeOpened: true, importFinalModal: { title: translateLocal('spreadsheet.importSuccessfullTitle'), - prompt: translateLocal('spreadsheet.importCategoriesSuccessfullDescription', categoriesLength), + prompt: translateLocal('spreadsheet.importCategoriesSuccessfullDescription', {categories: categoriesLength}), }, }, }, diff --git a/src/libs/actions/Policy/Member.ts b/src/libs/actions/Policy/Member.ts index 44ce9ea6f91c..f4d2287aca4c 100644 --- a/src/libs/actions/Policy/Member.ts +++ b/src/libs/actions/Policy/Member.ts @@ -183,7 +183,10 @@ function updateImportSpreadsheetData(membersLength: number): OnyxData { key: ONYXKEYS.IMPORTED_SPREADSHEET, value: { shouldFinalModalBeOpened: true, - importFinalModal: {title: translateLocal('spreadsheet.importSuccessfullTitle'), prompt: translateLocal('spreadsheet.importMembersSuccessfullDescription', membersLength)}, + importFinalModal: { + title: translateLocal('spreadsheet.importSuccessfullTitle'), + prompt: translateLocal('spreadsheet.importMembersSuccessfullDescription', {members: membersLength}), + }, }, }, ], @@ -602,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); @@ -611,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); @@ -637,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[] = [ { @@ -648,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[] = [ { @@ -662,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 db4fa4d417f6..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, @@ -4160,9 +4047,10 @@ function setPolicyAutomaticApprovalLimit(policyID: string, limit: string) { function setPolicyAutomaticApprovalRate(policyID: string, auditRate: string) { const policy = getPolicy(policyID); const fallbackAuditRate = auditRate === '' ? '0' : auditRate; - const parsedAuditRate = parseInt(fallbackAuditRate, 10); + const parsedAuditRate = parseInt(fallbackAuditRate, 10) / 100; - if (parsedAuditRate === policy?.autoApproval?.auditRate ?? CONST.POLICY.RANDOM_AUDIT_DEFAULT_PERCENTAGE) { + // The auditRate arrives as an int to this method so we will convert it to a float before sending it to the API. + if (parsedAuditRate === (policy?.autoApproval?.auditRate ?? CONST.POLICY.RANDOM_AUDIT_DEFAULT_PERCENTAGE)) { return; } @@ -4238,17 +4126,8 @@ function enableAutoApprovalOptions(policyID: string, enabled: boolean) { return; } - const autoApprovalCleanupValues = !enabled - ? { - pendingFields: { - limit: null, - auditRate: null, - }, - } - : {}; - const autoApprovalValues = !enabled ? {auditRate: CONST.POLICY.RANDOM_AUDIT_DEFAULT_PERCENTAGE, limit: CONST.POLICY.AUTO_APPROVE_REPORTS_UNDER_DEFAULT_CENTS} : {}; - const autoApprovalFailureValues = !enabled ? {autoApproval: {limit: policy?.autoApproval?.limit, auditRate: policy?.autoApproval?.auditRate, ...autoApprovalCleanupValues}} : {}; - + const autoApprovalValues = {auditRate: CONST.POLICY.RANDOM_AUDIT_DEFAULT_PERCENTAGE, limit: CONST.POLICY.AUTO_APPROVE_REPORTS_UNDER_DEFAULT_CENTS}; + const autoApprovalFailureValues = {autoApproval: {limit: policy?.autoApproval?.limit, auditRate: policy?.autoApproval?.auditRate, pendingFields: null}}; const optimisticData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, @@ -4274,7 +4153,7 @@ function enableAutoApprovalOptions(policyID: string, enabled: boolean) { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, value: { - autoApproval: {...autoApprovalCleanupValues}, + autoApproval: {pendingFields: null}, pendingFields: { shouldShowAutoApprovalOptions: null, }, @@ -4367,7 +4246,7 @@ function setPolicyAutoReimbursementLimit(policyID: string, limit: string) { ]; const parameters: SetPolicyAutoReimbursementLimitParams = { - autoReimbursement: {limit: parsedLimit}, + limit: parsedLimit, policyID, }; @@ -4380,6 +4259,7 @@ function setPolicyAutoReimbursementLimit(policyID: string, limit: string) { /** * Call the API to enable auto-payment for the reports in the given policy + * * @param policyID - id of the policy to apply the limit to * @param enabled - whether auto-payment for the reports is enabled in the given policy */ @@ -4390,16 +4270,8 @@ function enablePolicyAutoReimbursementLimit(policyID: string, enabled: boolean) return; } - const autoReimbursementCleanupValues = !enabled - ? { - pendingFields: { - limit: null, - }, - } - : {}; - const autoReimbursementFailureValues = !enabled ? {autoReimbursement: {limit: policy?.autoReimbursement?.limit, ...autoReimbursementCleanupValues}} : {}; - const autoReimbursementValues = !enabled ? {limit: CONST.POLICY.AUTO_REIMBURSEMENT_DEFAULT_LIMIT_CENTS} : {}; - + const autoReimbursementFailureValues = {autoReimbursement: {limit: policy?.autoReimbursement?.limit, pendingFields: null}}; + const autoReimbursementValues = {limit: CONST.POLICY.AUTO_REIMBURSEMENT_DEFAULT_LIMIT_CENTS}; const optimisticData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, @@ -4424,7 +4296,7 @@ function enablePolicyAutoReimbursementLimit(policyID: string, enabled: boolean) onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, value: { - autoReimbursement: {...autoReimbursementCleanupValues}, + autoReimbursement: {pendingFields: null}, pendingFields: { shouldShowAutoReimbursementLimitOption: null, }, diff --git a/src/libs/actions/Policy/Tag.ts b/src/libs/actions/Policy/Tag.ts index f2cd818fd6c1..9628b6ceda77 100644 --- a/src/libs/actions/Policy/Tag.ts +++ b/src/libs/actions/Policy/Tag.ts @@ -136,7 +136,10 @@ function updateImportSpreadsheetData(tagsLength: number): OnyxData { key: ONYXKEYS.IMPORTED_SPREADSHEET, value: { shouldFinalModalBeOpened: true, - importFinalModal: {title: translateLocal('spreadsheet.importSuccessfullTitle'), prompt: translateLocal('spreadsheet.importTagsSuccessfullDescription', tagsLength)}, + importFinalModal: { + title: translateLocal('spreadsheet.importSuccessfullTitle'), + prompt: translateLocal('spreadsheet.importTagsSuccessfullDescription', {tags: tagsLength}), + }, }, }, ], diff --git a/src/libs/actions/PriorityMode.ts b/src/libs/actions/PriorityMode.ts index beec327a2e40..2aca5d9f9de8 100644 --- a/src/libs/actions/PriorityMode.ts +++ b/src/libs/actions/PriorityMode.ts @@ -57,11 +57,11 @@ Onyx.connect({ }, }); -let hasTriedFocusMode: boolean | null | undefined; +let hasTriedFocusMode: boolean | undefined; Onyx.connect({ key: ONYXKEYS.NVP_TRY_FOCUS_MODE, callback: (val) => { - hasTriedFocusMode = val ?? null; + hasTriedFocusMode = val; // eslint-disable-next-line @typescript-eslint/no-use-before-define checkRequiredData(); 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/actions/connections/QuickbooksOnline.ts b/src/libs/actions/connections/QuickbooksOnline.ts index c62c97aa88ca..bb85c8f5223f 100644 --- a/src/libs/actions/connections/QuickbooksOnline.ts +++ b/src/libs/actions/connections/QuickbooksOnline.ts @@ -384,7 +384,7 @@ function updateQuickbooksOnlinePreferredExporter true; +const willBlurTextInputOnTapOutside: WillBlurTextInputOnTapOutside = () => !getIsNarrowLayout(); export default willBlurTextInputOnTapOutside; diff --git a/src/pages/MissingPersonalDetails/index.tsx b/src/pages/MissingPersonalDetails/index.tsx index 9a2266f6384b..5220f25be981 100644 --- a/src/pages/MissingPersonalDetails/index.tsx +++ b/src/pages/MissingPersonalDetails/index.tsx @@ -135,7 +135,7 @@ function MissingPersonalDetails() { if (countrySpecificZipRegex) { if (!countrySpecificZipRegex.test(values[INPUT_IDS.ZIP_POST_CODE]?.trim().toUpperCase())) { if (ValidationUtils.isRequiredFulfilled(values[INPUT_IDS.ZIP_POST_CODE]?.trim())) { - errors[INPUT_IDS.ZIP_POST_CODE] = translate('privatePersonalDetails.error.incorrectZipFormat', countryZipFormat); + errors[INPUT_IDS.ZIP_POST_CODE] = translate('privatePersonalDetails.error.incorrectZipFormat', {zipFormat: countryZipFormat}); } else { errors[INPUT_IDS.ZIP_POST_CODE] = translate('common.error.fieldRequired'); } diff --git a/src/pages/ReportDetailsPage.tsx b/src/pages/ReportDetailsPage.tsx index 66c61b134aef..195c14698f7d 100644 --- a/src/pages/ReportDetailsPage.tsx +++ b/src/pages/ReportDetailsPage.tsx @@ -22,6 +22,7 @@ import PromotedActionsBar, {PromotedActions} from '@components/PromotedActionsBa import RoomHeaderAvatars from '@components/RoomHeaderAvatars'; import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; +import {useSearchContext} from '@components/Search/SearchContext'; import Text from '@components/Text'; import useDelegateUserDetails from '@hooks/useDelegateUserDetails'; import useLocalize from '@hooks/useLocalize'; @@ -87,6 +88,7 @@ function ReportDetailsPage({policies, report, route}: ReportDetailsPageProps) { const [parentReportNameValuePairs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report?.parentReportID || '-1'}`); const {reportActions} = usePaginatedReportActions(report.reportID || '-1'); /* eslint-enable @typescript-eslint/prefer-nullish-coalescing */ + const {currentSearchHash} = useSearchContext(); const transactionThreadReportID = useMemo( () => ReportActionsUtils.getOneTransactionThreadReportID(report.reportID, reportActions ?? [], isOffline), @@ -491,10 +493,11 @@ function ReportDetailsPage({policies, report, route}: ReportDetailsPageProps) { /> ) : null; - const connectedIntegrationName = connectedIntegration ? translate('workspace.accounting.connectionName', connectedIntegration) : ''; + const connectedIntegrationName = connectedIntegration ? translate('workspace.accounting.connectionName', {connectionName: connectedIntegration}) : ''; const unapproveWarningText = ( - {translate('iou.headsUp')} {translate('iou.unapproveWithIntegrationWarning', connectedIntegrationName)} + {translate('iou.headsUp')}{' '} + {translate('iou.unapproveWithIntegrationWarning', {accountingIntegration: connectedIntegrationName})} ); @@ -571,6 +574,7 @@ function ReportDetailsPage({policies, report, route}: ReportDetailsPageProps) { reportID: transactionThreadReportID ? report.reportID : moneyRequestAction?.childReportID ?? '-1', isDelegateAccessRestricted, setIsNoDelegateAccessMenuVisible, + currentSearchHash, }), ); } @@ -582,7 +586,18 @@ function ReportDetailsPage({policies, report, route}: ReportDetailsPageProps) { result.push(PromotedActions.share(report, backTo)); return result; - }, [report, moneyRequestAction, canJoin, isExpenseReport, shouldShowHoldAction, canHoldUnholdReportAction.canHoldRequest, transactionThreadReportID, isDelegateAccessRestricted, backTo]); + }, [ + report, + moneyRequestAction, + currentSearchHash, + canJoin, + isExpenseReport, + shouldShowHoldAction, + canHoldUnholdReportAction.canHoldRequest, + transactionThreadReportID, + isDelegateAccessRestricted, + backTo, + ]); const nameSectionExpenseIOU = ( @@ -806,7 +821,7 @@ function ReportDetailsPage({policies, report, route}: ReportDetailsPageProps) { shouldEnableNewFocusManagement /> setIsDeleteModalVisible(false)} @@ -829,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/RestrictedAction/Workspace/WorkspaceAdminRestrictedAction.tsx b/src/pages/RestrictedAction/Workspace/WorkspaceAdminRestrictedAction.tsx index 89b5dcdd8a2b..342cd4ce5e6e 100644 --- a/src/pages/RestrictedAction/Workspace/WorkspaceAdminRestrictedAction.tsx +++ b/src/pages/RestrictedAction/Workspace/WorkspaceAdminRestrictedAction.tsx @@ -50,10 +50,10 @@ function WorkspaceAdminRestrictedAction({policyID}: WorkspaceAdminRestrictedActi height={variables.restrictedActionIllustrationHeight} /> - {translate('workspace.restrictedAction.actionsAreCurrentlyRestricted', {workspaceName: policy?.name})} + {translate('workspace.restrictedAction.actionsAreCurrentlyRestricted', {workspaceName: policy?.name ?? ''})} - {translate('workspace.restrictedAction.workspaceOwnerWillNeedToAddOrUpdatePaymentCard', {workspaceOwnerName: policy?.owner})} + {translate('workspace.restrictedAction.workspaceOwnerWillNeedToAddOrUpdatePaymentCard', {workspaceOwnerName: policy?.owner ?? ''})}