diff --git a/.eslintrc.js b/.eslintrc.js index ed6f162ad8d4..d24ea9766e19 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -100,7 +100,6 @@ module.exports = { __DEV__: 'readonly', }, rules: { - '@typescript-eslint/no-unsafe-call': 'off', '@typescript-eslint/no-unsafe-member-access': 'off', '@typescript-eslint/no-unsafe-assignment': 'off', @@ -167,6 +166,7 @@ module.exports = { // Rulesdir specific rules 'rulesdir/no-default-props': 'error', + 'rulesdir/prefer-type-fest': 'error', 'rulesdir/no-multiple-onyx-in-file': 'off', 'rulesdir/prefer-underscore-method': 'off', 'rulesdir/prefer-import-module-contents': 'off', diff --git a/.github/actions/javascript/getGraphiteString/getGraphiteString.ts b/.github/actions/javascript/getGraphiteString/getGraphiteString.ts index c486fdbd39f3..5231caa79ed5 100644 --- a/.github/actions/javascript/getGraphiteString/getGraphiteString.ts +++ b/.github/actions/javascript/getGraphiteString/getGraphiteString.ts @@ -1,6 +1,15 @@ import * as core from '@actions/core'; import fs from 'fs'; +type RegressionEntry = { + metadata?: { + creationDate: string; + }; + name: string; + meanDuration: number; + meanCount: number; +}; + const run = () => { // Prefix path to the graphite metric const GRAPHITE_PATH = 'reassure'; @@ -24,11 +33,11 @@ const run = () => { } try { - const current = JSON.parse(entry); + const current: RegressionEntry = JSON.parse(entry); // Extract timestamp, Graphite accepts timestamp in seconds if (current.metadata?.creationDate) { - timestamp = Math.floor(new Date(current.metadata.creationDate as string).getTime() / 1000); + timestamp = Math.floor(new Date(current.metadata.creationDate).getTime() / 1000); } if (current.name && current.meanDuration && current.meanCount && timestamp) { diff --git a/.github/libs/GitUtils.ts b/.github/libs/GitUtils.ts index 684e7c76aa86..dc8ae037be28 100644 --- a/.github/libs/GitUtils.ts +++ b/.github/libs/GitUtils.ts @@ -66,11 +66,11 @@ function getCommitHistoryAsJSON(fromTag: string, toTag: string): Promise { + spawnedProcess.stdout.on('data', (chunk: Buffer) => { console.log(chunk.toString()); stdout += chunk.toString(); }); - spawnedProcess.stderr.on('data', (chunk) => { + spawnedProcess.stderr.on('data', (chunk: Buffer) => { console.error(chunk.toString()); stderr += chunk.toString(); }); diff --git a/.github/scripts/enforceRedirect.sh b/.github/scripts/enforceRedirect.sh new file mode 100755 index 000000000000..4d7d169b01c5 --- /dev/null +++ b/.github/scripts/enforceRedirect.sh @@ -0,0 +1,35 @@ +#!/bin/bash + +# HelpDot - Whenever an article is moved/renamed/deleted we should verify that +# we have added a redirect link for it in redirects.csv. This ensures that we don't have broken links. + +declare -r RED='\033[0;31m' +declare -r GREEN='\033[0;32m' +declare -r NC='\033[0m' + +declare -r ARTICLES_DIRECTORY="docs/articles" +declare -r REDIRECTS_FILE="docs/redirects.csv" + +hasRenamedOrDeletedArticle=false +hasModifiedRedirect=false + +if git log origin/main..HEAD --name-status --pretty=format: $ARTICLES_DIRECTORY | grep -q -E "^(R|D)" +then + echo "Articles have been renamed/moved/deleted" + hasRenamedOrDeletedArticle=true +fi + +if git log origin/main..HEAD --name-status --pretty=format: $REDIRECTS_FILE | grep -q -E "^(M)" +then + echo "Redirects.csv has been modified" + hasModifiedRedirect=true +fi + +if [[ $hasRenamedOrDeletedArticle == true && $hasModifiedRedirect == false ]] +then + echo -e "${RED}Articles have been renamed or deleted. Please add a redirect link for the old article links in redirects.csv${NC}" + exit 1 +fi + +echo -e "${GREEN}Articles aren't moved or deleted, or a redirect has been added. Please verify that a redirect has been added for all the files moved or deleted${NC}" +exit 0 diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 63148f9e4eb5..a792d069151b 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -14,7 +14,7 @@ jobs: with: ref: staging token: ${{ secrets.OS_BOTIFY_TOKEN }} - + - name: Setup git for OSBotify uses: ./.github/actions/composite/setupGitForOSBotifyApp id: setupGitForOSBotify @@ -65,9 +65,6 @@ jobs: PR_LIST: ${{ steps.getReleasePRList.outputs.PR_LIST }} - name: 🚀 Create release to trigger production deploy 🚀 - uses: softprops/action-gh-release@affa18ef97bc9db20076945705aba8c516139abd - with: - tag_name: ${{ env.PRODUCTION_VERSION }} - body: ${{ steps.getReleaseBody.outputs.RELEASE_BODY }} + run: gh release create ${{ env.PRODUCTION_VERSION }} --notes ${{ steps.getReleaseBody.outputs.RELEASE_BODY }} env: GITHUB_TOKEN: ${{ steps.setupGitForOSBotify.outputs.OS_BOTIFY_API_TOKEN }} diff --git a/.github/workflows/deployExpensifyHelp.yml b/.github/workflows/deployExpensifyHelp.yml index cda33d39102e..8a6237832340 100644 --- a/.github/workflows/deployExpensifyHelp.yml +++ b/.github/workflows/deployExpensifyHelp.yml @@ -29,9 +29,12 @@ jobs: env: IS_PR_FROM_FORK: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork }} runs-on: ubuntu-latest + continue-on-error: true steps: - name: Checkout uses: actions/checkout@v4 + with: + fetch-depth: 0 - name: Setup NodeJS uses: ./.github/actions/composite/setupNode @@ -42,6 +45,9 @@ jobs: - name: Check for duplicates and cycles in redirects.csv run: ./.github/scripts/verifyRedirect.sh + - name: Enforce that a redirect link has been created + run: ./.github/scripts/enforceRedirect.sh + - name: Build with Jekyll uses: actions/jekyll-build-pages@0143c158f4fa0c5dcd99499a5d00859d79f70b0e with: diff --git a/.github/workflows/testGithubActionsWorkflows.yml b/.github/workflows/testGithubActionsWorkflows.yml index d052b343cf0c..f65319f14be4 100644 --- a/.github/workflows/testGithubActionsWorkflows.yml +++ b/.github/workflows/testGithubActionsWorkflows.yml @@ -25,6 +25,13 @@ jobs: - name: Setup Homebrew uses: Homebrew/actions/setup-homebrew@master + - name: Login to GitHub Container Regstry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: OSBotify + password: ${{ secrets.GITHUB_TOKEN }} + - name: Install Act run: brew install act diff --git a/.storybook/webpack.config.ts b/.storybook/webpack.config.ts index 4d638020cd42..09d881846b1e 100644 --- a/.storybook/webpack.config.ts +++ b/.storybook/webpack.config.ts @@ -3,6 +3,7 @@ /* eslint-disable no-param-reassign */ /* eslint-disable @typescript-eslint/naming-convention */ +import type Environment from 'config/webpack/types'; import dotenv from 'dotenv'; import path from 'path'; import {DefinePlugin} from 'webpack'; @@ -18,6 +19,8 @@ type CustomWebpackConfig = { }; }; +type CustomWebpackFunction = ({file, platform}: Environment) => CustomWebpackConfig; + let envFile: string; switch (process.env.ENV) { case 'production': @@ -31,9 +34,9 @@ switch (process.env.ENV) { } const env = dotenv.config({path: path.resolve(__dirname, `../${envFile}`)}); -const custom: CustomWebpackConfig = require('../config/webpack/webpack.common').default({ - envFile, -}); +const customFunction: CustomWebpackFunction = require('../config/webpack/webpack.common').default; + +const custom: CustomWebpackConfig = customFunction({file: envFile}); const webpackConfig = ({config}: {config: Configuration}) => { if (!config.resolve) { diff --git a/android/app/build.gradle b/android/app/build.gradle index 9343423c9f14..402cd5a61bd6 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -107,8 +107,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1001048016 - versionName "1.4.80-16" + versionCode 1001048204 + versionName "1.4.82-4" // 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/animations/Abracadabra.lottie b/assets/animations/Abracadabra.lottie new file mode 100644 index 000000000000..8805aed1944e Binary files /dev/null and b/assets/animations/Abracadabra.lottie differ diff --git a/assets/animations/MagicCode.lottie b/assets/animations/MagicCode.lottie new file mode 100644 index 000000000000..ea94f1138f97 Binary files /dev/null and b/assets/animations/MagicCode.lottie differ diff --git a/assets/images/simple-illustrations/simple-illustration__creditcardeyes.svg b/assets/images/simple-illustrations/simple-illustration__creditcardeyes.svg new file mode 100644 index 000000000000..17ff47e6ca12 --- /dev/null +++ b/assets/images/simple-illustrations/simple-illustration__creditcardeyes.svg @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/config/webpack/webpack.common.ts b/config/webpack/webpack.common.ts index 9d397b9557a3..bedd7e50ef94 100644 --- a/config/webpack/webpack.common.ts +++ b/config/webpack/webpack.common.ts @@ -4,14 +4,27 @@ import dotenv from 'dotenv'; import fs from 'fs'; import HtmlWebpackPlugin from 'html-webpack-plugin'; import path from 'path'; -import type {Configuration} from 'webpack'; +import type {Compiler, Configuration} from 'webpack'; import {DefinePlugin, EnvironmentPlugin, IgnorePlugin, ProvidePlugin} from 'webpack'; import {BundleAnalyzerPlugin} from 'webpack-bundle-analyzer'; import CustomVersionFilePlugin from './CustomVersionFilePlugin'; import type Environment from './types'; +// importing anything from @vue/preload-webpack-plugin causes an error +type Options = { + rel: string; + as: string; + fileWhitelist: RegExp[]; + include: string; +}; + +type PreloadWebpackPluginClass = { + new (options?: Options): PreloadWebpackPluginClass; + apply: (compiler: Compiler) => void; +}; + // require is necessary, there are no types for this package and the declaration file can't be seen by the build process which causes an error. -const PreloadWebpackPlugin = require('@vue/preload-webpack-plugin'); +const PreloadWebpackPlugin: PreloadWebpackPluginClass = require('@vue/preload-webpack-plugin'); const includeModules = [ 'react-native-animatable', diff --git a/desktop/createDownloadQueue.ts b/desktop/createDownloadQueue.ts index 132848c5da9e..4403f989263c 100644 --- a/desktop/createDownloadQueue.ts +++ b/desktop/createDownloadQueue.ts @@ -17,6 +17,11 @@ type DownloadItem = { options: Options; }; +type CreateDownloadQueue = () => { + enqueueDownloadItem: (item: DownloadItem) => void; + dequeueDownloadItem: () => DownloadItem | undefined; +}; + /** * Returns the filename with extension based on the given name and MIME type. * @param name - The name of the file. @@ -28,7 +33,7 @@ const getFilenameFromMime = (name: string, mime: string): string => { return `${name}.${extensions}`; }; -const createDownloadQueue = () => { +const createDownloadQueue: CreateDownloadQueue = () => { const downloadItemProcessor = (item: DownloadItem): Promise => new Promise((resolve, reject) => { let downloadTimeout: NodeJS.Timeout; @@ -114,3 +119,4 @@ const createDownloadQueue = () => { }; export default createDownloadQueue; +export type {DownloadItem, CreateDownloadQueue}; diff --git a/desktop/main.ts b/desktop/main.ts index 0f4774d3b73b..57ea647cc3e2 100644 --- a/desktop/main.ts +++ b/desktop/main.ts @@ -13,9 +13,10 @@ import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; import type PlatformSpecificUpdater from '@src/setup/platformSetup/types'; import type {Locale} from '@src/types/onyx'; +import type {CreateDownloadQueue, DownloadItem} from './createDownloadQueue'; import ELECTRON_EVENTS from './ELECTRON_EVENTS'; -const createDownloadQueue = require('./createDownloadQueue').default; +const createDownloadQueue: CreateDownloadQueue = require('./createDownloadQueue').default; const port = process.env.PORT ?? 8082; const {DESKTOP_SHORTCUT_ACCELERATOR, LOCALES} = CONST; @@ -617,7 +618,7 @@ const mainWindow = (): Promise => { const downloadQueue = createDownloadQueue(); ipcMain.on(ELECTRON_EVENTS.DOWNLOAD, (event, downloadData) => { - const downloadItem = { + const downloadItem: DownloadItem = { ...downloadData, win: browserWindow, }; diff --git a/docs/_data/_routes.yml b/docs/_data/_routes.yml index ad738e44ab44..5fd65532c021 100644 --- a/docs/_data/_routes.yml +++ b/docs/_data/_routes.yml @@ -71,7 +71,7 @@ platforms: - href: integrations title: Integrations - icon: /assets/images/workflow.svg + icon: /assets/images/simple-illustration__monitor-remotesync.svg description: Integrate with accounting or HR software to streamline expense approvals. - href: spending-insights @@ -131,7 +131,7 @@ platforms: - href: connections title: Connections - icon: /assets/images/workflow.svg + icon: /assets/images/simple-illustration__monitor-remotesync.svg description: Connect to accounting software to streamline expense approvals. - href: settings diff --git a/docs/articles/expensify-classic/connect-credit-cards/company-cards/Troubleshooting.md b/docs/articles/expensify-classic/connect-credit-cards/company-cards/Troubleshooting.md index 814bf8fc559b..05366a91d9fa 100644 --- a/docs/articles/expensify-classic/connect-credit-cards/company-cards/Troubleshooting.md +++ b/docs/articles/expensify-classic/connect-credit-cards/company-cards/Troubleshooting.md @@ -64,8 +64,9 @@ Make sure you're importing your card in the correct spot in Expensify and select Please note there are some things that cannot be bypassed within Expensify, including two-factor authentication being enabled within your bank account. This will prevent the connection from remaining stable and will need to be turned off on the bank side. ## What are the most reliable bank connections in Expensify?* -The most reliable corporate card to use with Expensify is the Expensify Visa® Commercial Card. We offer daily settlement, unapproved expense limits, and real-time compliance for secure and efficient spending, as well as 2% cash back (_Applies to USD purchases only._) Click here to learn more or apply. -Additionally, we've teamed up with major banks worldwide to ensure a smooth import of credit card transactions into your accounts. Corporate cards from the following banks also offer the most dependable connections in Expensify: +All bank connections listed below are extremely reliable, but we recommend transacting with the Expensify Visa® Commercial Card. It also offers daily and monthly settlement, unapproved expense limits, realtime compliance for secure and efficient spending, and cash back on all US purchases. [Click here to learn more about the Expensify Card](https://use.expensify.com/company-credit-card). + +We've also teamed up with major banks worldwide to ensure a smooth import of credit card transactions into your accounts: - American Express - Bank of America - Brex @@ -75,7 +76,7 @@ Additionally, we've teamed up with major banks worldwide to ensure a smooth impo - Stripe - Wells Fargo -Commercial feeds for company cards are the dependable connections in Expensify. If you have a corporate or commercial card account, you might have access to a daily transaction feed where expenses from Visa, MasterCard, and American Express are automatically sent to Expensify. Reach out to your banking relationship manager to check if your card program qualifies for this feature. +Commercial feeds for company cards are the dependable connections in Expensify. If you have a corporate or commercial card account, you might have access to a daily transaction feed where expenses from Visa, Mastercard, and American Express are automatically sent to Expensify. Reach out to your banking relationship manager to check if your card program qualifies for this feature. # Troubleshooting American Express Business diff --git a/docs/articles/expensify-classic/getting-started/playbooks/Expensify-Playbook-For-Small-To-Medium-Sized-Businesses.md b/docs/articles/expensify-classic/getting-started/playbooks/Expensify-Playbook-For-Small-To-Medium-Sized-Businesses.md index 42d06d45fa87..411cc64eda7f 100644 --- a/docs/articles/expensify-classic/getting-started/playbooks/Expensify-Playbook-For-Small-To-Medium-Sized-Businesses.md +++ b/docs/articles/expensify-classic/getting-started/playbooks/Expensify-Playbook-For-Small-To-Medium-Sized-Businesses.md @@ -194,14 +194,11 @@ As mentioned above, we’ll be able to pull in transactions as they post (daily) ### If you don't have a corporate card, use the Expensify Card (US only) Expensify provides a corporate card with the following features: -- Up to 2% cash back (_Applies to USD purchases only._) -- [SmartLimits](https://help.expensify.com/articles/expensify-classic/expensify-card/Cardholder-Settings-and-Features) to control what each individual cardholder can spend -- A stable, unbreakable real-time connection (third-party bank feeds can run into connectivity issues) -- Receipt compliance - informing notifications (e.g. add a receipt!) for users *as soon as the card is swiped* -- Unlimited Virtual Cards - single-purpose cards with a fixed or monthly limit for specific company purchases -- A 50% discount on the price of all Expensify plans -- Multiple discounts and savings on a host of partner tech suppliers -- Good Karma - 10% of all card interchange we earn goes directly to the Expensify.org Social Justice Community funds +- Finish your expenses in a swipe, we'll take care of everything else +- Get cash back on every US purchase and up to 50% off your monthly Expensify bill +- Stay in control with realtime alerts, spend limits, and auto-reconciliation +- Don't worry about credit checks, annual fees, or personal guarantees +- Create unlimited virtual cards with fixed or monthly limits for specific purchases The Expensify Card is recommended as the most efficient way to manage your company's spending. diff --git a/docs/articles/expensify-classic/getting-started/playbooks/Expensify-Playbook-For-US-Based-VC-Backed-Startups.md b/docs/articles/expensify-classic/getting-started/playbooks/Expensify-Playbook-For-US-Based-VC-Backed-Startups.md index 30d3b3e7732c..0f6df238db5b 100644 --- a/docs/articles/expensify-classic/getting-started/playbooks/Expensify-Playbook-For-US-Based-VC-Backed-Startups.md +++ b/docs/articles/expensify-classic/getting-started/playbooks/Expensify-Playbook-For-US-Based-VC-Backed-Startups.md @@ -155,11 +155,12 @@ The Expensify Card has many benefits for your company. Two in particular are wor ### If you don't have a corporate card, use the Expensify Card Expensify provides a corporate card with the following features: -- Up to 2% cash back (Applies to USD purchases only) -- [SmartLimits](https://community.expensify.com/discussion/4851/deep-dive-what-are-unapproved-expense-limits#latest) -- Unlimited Virtual Cards - single-purpose cards with a fixed or monthly limit for specific company purchases -- A stable, unbreakable connection (third-party bank feeds can run into connectivity issues) - +- Finish your expenses in a swipe, we'll take care of everything else +- Get cash back on every US purchase and up to 50% off your monthly Expensify bill +- Stay in control with realtime alerts, spend limits, and auto-reconciliation +- Don't worry about credit checks, annual fees, or personal guarantees +- Create unlimited virtual cards with fixed or monthly limits for specific purchases + The Expensify Card is recommended as the most efficient way to manage your company's spending. Here’s how to enable it: diff --git a/docs/articles/new-expensify/expenses/Connect-a-Business-Bank-Account.md b/docs/articles/new-expensify/expenses/Connect-a-Business-Bank-Account.md index 8cf0a18ba529..96653bc69763 100644 --- a/docs/articles/new-expensify/expenses/Connect-a-Business-Bank-Account.md +++ b/docs/articles/new-expensify/expenses/Connect-a-Business-Bank-Account.md @@ -4,69 +4,51 @@ description: How to connect a business bank account to New Expensify ---
-Adding a business bank account unlocks a myriad of features and automation in Expensify, such as: -- Reimburse expenses via direct bank transfer -- Pay bills -- Collect invoice payments -- Issue the Expensify Card - -# To connect a bank account -1. Go to **Settings > Workspaces > _Workspace Name_ > Bank account > Connect bank account** -2. Click **Connect online with Plaid** -3. Click **Continue** -4. When you reach the **Plaid** screen, you'll be shown a list of compatible banks that offer direct online login access -5. Login to the business bank account: -- If the bank is not listed, click the X to go back to the connection type -- Here you’ll see the option to **Connect Manually** -- Enter your account and routing numbers -6. Enter your bank login credentials: -- If your bank requires additional security measures, you will be directed to obtain and enter a security code -- If you have more than one account available to choose from, you will be directed to choose the desired account - -## Enter company information -This is where you’ll add the legal business name as well as several other company details. - -- **Company address**: The company address must be located in the US and a physical location (If you input a maildrop address, PO box, or UPS Store, the address will be flagged for review, and adding the bank account to Expensify will be delayed) -- **Tax Identification Number**: This is the identification number that was assigned to the business by the IRS -- **Company website**: A company website is required to use most of Expensify’s payment features. When adding the website of the business, format it as, https://www.domain.com -- **Industry Classification Code**: You can locate a list of Industry Classification Codes [here]([url](https://www.census.gov/naics/?input=software&year=2022)) - -## Enter personal information -Whoever is connecting the bank account to Expensify, must enter their details under the Requestor Information section: -- The address must be a physical address -- The address must be located in the US -- The SSN must be US-issued - -This does not need to be a signor on the bank account. If someone other than the Expensify account owner enters their personal information in this section, the details will be flagged for review, and adding the bank account to Expensify will be delayed. - -## Upload ID +To connect a bank account in New Expensify, you must first enable the Make or Track Payments Workflow. +# Step 1: Enable Make or track payments +1. Head to **Workspaces** > **More Features** > **Enable Workflows** +2. From there, a Workflows setting will appear in the left-hand menu +3. Click on **Workflows** +4. Enable **Make or track payments** + +# Step 2: Connect bank account +1. Click Connect bank account +2. Select either Connect online with Plaid (preferred) or Connect manually +3. Enter bank details + +# Step 4: Upload ID After entering your personal details, you’ll be prompted to click a link or scan a QR code so that you can do the following: 1. Upload a photo of the front and back of your ID (this cannot be a photo of an existing image) -2. Use your device to take a selfie and record a short video of yourself +2. Use your device to take a selfie and record a short video of yourself **Your ID must be:** - Issued in the US - Current (ie: the expiration date must be in the future) -## Additional Information -Check the appropriate box under **Additional Information**, accept the agreement terms, and verify that all of the information is true and accurate: -- A Beneficial Owner refers to an **individual** who owns 25% or more of the business. -- If you or another **individual** owns 25% or more of the business, please check the appropriate box +# Step 5: Enter company information +This is where you’ll add the legal business name as well as several other company details. +- **Company address:** The company address must be located in the US and a physical location (If you input a maildrop address, PO box, or UPS Store, the address will be flagged for review, and adding the bank account to Expensify will be delayed) +- **Tax Identification Number:** This is the identification number that was assigned to the business by the IRS +- **Company website:** A company website is required to use most of Expensify’s payment features. +- **Industry Classification Code:** You can locate a list of Industry Classification Codes [here](https://www.census.gov/naics/?input=software&year=2022). + +# Step 6: Additional Information +Check the appropriate box under Additional Information, accept the agreement terms, and verify that all of the information is true and accurate: +- A Beneficial Owner refers to an individual who owns 25% or more of the business. +- If you or another individual owns 25% or more of the business, please check the appropriate box - If someone else owns 25% or more of the business, you will be prompted to provide their personal information If no individual owns more than 25% of the company you do not need to list any beneficial owners. In that case, be sure to leave both boxes unchecked under the Beneficial Owner Additional Information section. -The details you submitted may require additional review. If that's the case, you'll receive a message from the Concierge outlining the next steps. Otherwise, your bank account will be connected automatically. +The details you submitted may require additional review. If that's the case, you'll receive a message from the Concierge outlining the next steps. Otherwise, your bank account will be connected automatically. {% include faq-begin.md %} ## What are the general requirements for adding a business bank account? -To add a business bank account to issue reimbursements via ACH (US), to pay invoices (US), or to issue Expensify Cards: -- You must enter a physical address for yourself, any Beneficial Owner (if one exists), and the business associated with the bank account. We **cannot** accept a PO Box or MailDrop location. -If you are adding the bank account to Expensify, you must do so from your Expensify account settings. -- If you are adding a bank account to Expensify, we are required by law to verify your identity. Part of this process requires you to verify a US-issued photo ID. For using features related to US ACH, your ID must be issued by the United States. You and any Beneficial Owner (if one exists), must also have a US address -- You must have a valid website for your business to utilize the Expensify Card, or to pay invoices with Expensify. +To add a business bank account to issue reimbursements via ACH (US) or to issue Expensify Cards: +- You must enter a physical address for yourself, any Beneficial Owner (if one exists), and the business associated with the bank account. We cannot accept a PO Box or MailDrop location. +- If you are adding the bank account to Expensify, we are required by law to verify your identity. Part of this process requires you to verify a US-issued photo ID. Your ID must be issued by the United States to use features related to US ACH. You and any Beneficial Owner (if one exists) must also have a US address. ## What is a Beneficial Owner? @@ -74,25 +56,27 @@ A Beneficial Owner refers to an **individual** who owns 25% or more of the busin ## What do I do if the Beneficial Owner section only asks for personal details, but my organization is owned by another company? -Please indicate you have a Beneficial Owner only if it is an individual who owns 25% or more of the business. +Please indicate you have a Beneficial Owner only if it is an individual who owns 25% or more of the business. ## Why can’t I input my address or upload my ID? -Are you entering a US address? When adding a verified business bank account in Expensify, the individual adding the account and any beneficial owner (if one exists) are required to have a US address, US photo ID, and a US SSN. If you do not meet these requirements, you’ll need to have another admin add the bank account and then share access with you once it is verified. +When adding a verified business bank account in Expensify, the individual adding the account and any beneficial owner (if one exists) are required to have a US address, US photo ID, and a US SSN. If you do not meet these requirements, you’ll need to have another admin add the bank account and then share access with you once it is verified. ## Why am I asked for documents when adding my bank account? -When a bank account is added to Expensify, we complete a series of checks to verify the information provided to us. We conduct these checks to comply with both our sponsor bank's requirements and federal government regulations, specifically the Bank Secrecy Act / Anti-Money Laundering (BSA / AML) laws. Expensify also has anti-fraud measures in place. -If automatic verification fails, we may request manual verification, which could involve documents such as address verification for your business, a letter from your bank confirming bank account ownership, etc. +When a bank account is added to Expensify, we complete a series of checks to verify the information provided to us. We conduct these checks to comply with both our sponsor bank's requirements and federal government regulations, specifically the Bank Secrecy Act / Anti-Money Laundering (BSA / AML) laws. Expensify also has anti-fraud measures in place. + +If automatic verification fails, we may request manual verification, which could involve documents such as address verification for your business, a letter from your bank confirming bank account ownership, etc. If you have any questions regarding the documentation request you received, please contact Concierge and they will be happy to assist. ## I don’t see all three microtransactions I need to validate my bank account. What should I do? -It's a good idea to wait until the end of that second business day. If you still don’t see them, please contact your bank and ask them to whitelist our ACH IDs **1270239450**, **4270239450**, and **2270239450**. Expensify’s ACH Originator Name is "Expensify." +Wait until the end of the second business day. If you still don’t see them, please contact your bank and ask them to whitelist our ACH IDs **1270239450**, **4270239450**, and **2270239450**. Expensify’s ACH Originator Name is "Expensify." Once that's all set, make sure to contact your account manager or concierge, and our team will be able to re-trigger those three test transactions! + {% include faq-end.md %}
diff --git a/docs/articles/new-expensify/settings/Add-profile-photo.md b/docs/articles/new-expensify/settings/Add-profile-photo.md new file mode 100644 index 000000000000..60e56deaafbc --- /dev/null +++ b/docs/articles/new-expensify/settings/Add-profile-photo.md @@ -0,0 +1,21 @@ +--- +title: Add profile photo +description: Add an image to your profile +--- +
+ +{% include selector.html values="desktop, mobile" %} + +{% include option.html value="desktop" %} +1. Click your profile image or icon in the bottom left menu. +2. Click the Edit pencil icon next to your profile image or icon and select **Upload Image** to choose a new image from your saved files. +{% include end-option.html %} + +{% include option.html value="mobile" %} +1. Tap your profile image or icon at the bottom of the screen. +2. Tap the Edit pencil icon next to your profile image or icon and select **Upload Image** to choose a new image from your saved files. +{% include end-option.html %} + +{% include end-selector.html %} + +
diff --git a/docs/articles/new-expensify/travel/Book-with-Expensify-Travel.md b/docs/articles/new-expensify/travel/Book-with-Expensify-Travel.md new file mode 100644 index 000000000000..5d25670ac5ab --- /dev/null +++ b/docs/articles/new-expensify/travel/Book-with-Expensify-Travel.md @@ -0,0 +1,89 @@ +--- +title: Book with Expensify Travel +description: Book flights, hotels, cars, trains, and more with Expensify Travel +--- +
+ +Expensify Travel allows members to search and book flights, hotels, cars, and trains globally at the most competitive rates available. + +With Expensify Travel, you can: +- Search and book travel arrangements all in one place +- Book travel for yourself or for someone else +- Get real-time support by chat or phone +- Manage all your T&E expenses in Expensify +- Create specific rules for booking travel +- Enable approvals for out-of-policy trips +- Book with any credit card on the market +- Book with the Expensify Card to get cash back and automatically reconcile transactions + +There is a flat fee of $15 per trip booked. A single trip can include multiple bookings, such as a flight, a hotel, and a car rental. + +# Book travel + +To book travel from the Expensify web app, + +1. Click the **Travel** tab. +2. Click **Book or manage travel**. +3. Use the icons at the top to select the type of travel arrangement you want to book: flights, hotels, cars, or trains. +4. Enter the travel information relevant to the travel arrangement selected (for example, the destination, dates of travel, etc.). +5. Select all the details for the arrangement you want to book. +6. Review the booking details and click **Book Flight / Book Hotel / Book Car / Book Rail** to complete the booking. + +The traveler is emailed an itinerary of the booking. Additionally, +- Their travel details are added to a Trip chat room under their primary workspace. +- An expense report for the trip is created. +- If booked with an Expensify Card, the trip is automatically reconciled. + +{% include info.html %} +The travel itinerary is also emailed to the traveler’s [copilots](https://help.expensify.com/articles/expensify-classic/copilots-and-delegates/Assign-or-remove-a-Copilot), if applicable. +{% include end-info.html %} + +
+ +
+Expensify Travel allows members to search and book flights, hotels, cars, and trains globally at the most competitive rates available. + +With Expensify Travel, you can: +- Search and book travel arrangements all in one place +- Book travel for yourself or for someone else +- Get real-time support by chat or phone +- Manage all your T&E expenses in Expensify +- Create specific rules for booking travel +- Enable approvals for out-of-policy trips +- Book with any credit card on the market +- Book with the Expensify Card to get cash back and automatically reconcile transactions + +There is a flat fee of $15 per trip booked. A single trip can include multiple bookings, such as a flight, a hotel, and a car rental. + +# Book travel + +{% include selector.html values="desktop, mobile" %} + +{% include option.html value="desktop" %} +1. Click the + icon in the bottom left menu and select **Book travel**. +2. Click **Book or manage travel**. +3. Agree to the terms and conditions and click **Continue**. +4. Use the icons at the top to select the type of travel arrangement you want to book: flights, hotels, cars, or trains. +5. Enter the travel information relevant to the travel arrangement selected (for example, the destination, dates of travel, etc.). +6. Select all the details for the arrangement you want to book. +7. Review the booking details and click **Book Flight / Book Hotel / Book Car / Book Rail** to complete the booking. +{% include end-option.html %} + +{% include option.html value="mobile" %} +1. Tap the + icon in the bottom menu and select **Book travel**. +2. Tap **Book or manage travel**. +3. Agree to the terms and conditions and tap **Continue**. +4. Use the icons at the top to select the type of travel arrangement you want to book: flights, hotels, cars, or trains. +5. Enter the travel information relevant to the travel arrangement selected (for example, the destination, dates of travel, etc.). +6. Select all the details for the arrangement you want to book. +7. Review the booking details and click **Book Flight / Book Hotel / Book Car / Book Rail** to complete the booking. +{% include end-option.html %} + +{% include end-selector.html %} + +The traveler is emailed an itinerary of the booking. Additionally, +- Their travel details are added to a Trip chat room under their primary workspace. +- An expense report for the trip is created. +- If booked with an Expensify Card, the trip is automatically reconciled. + +
diff --git a/docs/assets/images/simple-illustration__monitor-remotesync.svg b/docs/assets/images/simple-illustration__monitor-remotesync.svg new file mode 100644 index 000000000000..e4ed84a35851 --- /dev/null +++ b/docs/assets/images/simple-illustration__monitor-remotesync.svg @@ -0,0 +1,30 @@ + + + + + + + + + \ No newline at end of file diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index ad1d743d778d..dae86af11b18 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -19,7 +19,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.4.80 + 1.4.82 CFBundleSignature ???? CFBundleURLTypes @@ -40,7 +40,7 @@ CFBundleVersion - 1.4.80.16 + 1.4.82.4 FullStory OrgId diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index 23bdf74a3648..1d3f54796afd 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -15,10 +15,10 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 1.4.80 + 1.4.82 CFBundleSignature ???? CFBundleVersion - 1.4.80.16 + 1.4.82.4 diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index 9f8cc7b1612e..064b395be9c7 100644 --- a/ios/NotificationServiceExtension/Info.plist +++ b/ios/NotificationServiceExtension/Info.plist @@ -11,9 +11,9 @@ CFBundleName $(PRODUCT_NAME) CFBundleShortVersionString - 1.4.80 + 1.4.82 CFBundleVersion - 1.4.80.16 + 1.4.82.4 NSExtension NSExtensionPointIdentifier diff --git a/ios/Podfile.lock b/ios/Podfile.lock index c138d1b27f61..aca46d6b18ed 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1852,7 +1852,7 @@ PODS: - RNGoogleSignin (10.0.1): - GoogleSignIn (~> 7.0) - React-Core - - RNLiveMarkdown (0.1.82): + - RNLiveMarkdown (0.1.83): - glog - hermes-engine - RCT-Folly (= 2022.05.16.00) @@ -1870,9 +1870,9 @@ PODS: - React-utils - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - - RNLiveMarkdown/common (= 0.1.82) + - RNLiveMarkdown/common (= 0.1.83) - Yoga - - RNLiveMarkdown/common (0.1.82): + - RNLiveMarkdown/common (0.1.83): - glog - hermes-engine - RCT-Folly (= 2022.05.16.00) @@ -2589,7 +2589,7 @@ SPEC CHECKSUMS: RNFS: 4ac0f0ea233904cb798630b3c077808c06931688 RNGestureHandler: 74b7b3d06d667ba0bbf41da7718f2607ae0dfe8f RNGoogleSignin: ccaa4a81582cf713eea562c5dd9dc1961a715fd0 - RNLiveMarkdown: d160a948e52282067439585c89a3962582c082ce + RNLiveMarkdown: 88030b7d9a31f5f6e67743df48ad952d64513b4a RNLocalize: d4b8af4e442d4bcca54e68fc687a2129b4d71a81 rnmapbox-maps: df8fe93dbd251f25022f4023d31bc04160d4d65c RNPermissions: 0b61d30d21acbeafe25baaa47d9bae40a0c65216 diff --git a/jest/setup.ts b/jest/setup.ts index 174e59a7e493..416306ce8426 100644 --- a/jest/setup.ts +++ b/jest/setup.ts @@ -21,8 +21,8 @@ jest.mock('react-native-onyx/dist/storage', () => mockStorage); jest.mock('react-native/Libraries/EventEmitter/NativeEventEmitter'); // Turn off the console logs for timing events. They are not relevant for unit tests and create a lot of noise -jest.spyOn(console, 'debug').mockImplementation((...params) => { - if (params[0].indexOf('Timing:') === 0) { +jest.spyOn(console, 'debug').mockImplementation((...params: string[]) => { + if (params[0].startsWith('Timing:')) { return; } diff --git a/package-lock.json b/package-lock.json index aaef29c6fad9..9b8f74c7d7e8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,19 +1,19 @@ { "name": "new.expensify", - "version": "1.4.80-16", + "version": "1.4.82-4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "1.4.80-16", + "version": "1.4.82-4", "hasInstallScript": true, "license": "MIT", "dependencies": { "@babel/plugin-proposal-private-methods": "^7.18.6", "@babel/plugin-proposal-private-property-in-object": "^7.21.11", "@dotlottie/react-player": "^1.6.3", - "@expensify/react-native-live-markdown": "0.1.82", + "@expensify/react-native-live-markdown": "0.1.83", "@expo/metro-runtime": "~3.1.1", "@formatjs/intl-datetimeformat": "^6.10.0", "@formatjs/intl-listformat": "^7.2.2", @@ -26,7 +26,7 @@ "@gorhom/portal": "^1.0.14", "@invertase/react-native-apple-authentication": "^2.2.2", "@kie/act-js": "^2.6.0", - "@kie/mock-github": "^1.0.0", + "@kie/mock-github": "2.0.1", "@onfido/react-native-sdk": "10.6.0", "@react-native-camera-roll/camera-roll": "7.4.0", "@react-native-clipboard/clipboard": "^1.13.2", @@ -64,6 +64,7 @@ "expo-av": "~13.10.4", "expo-image": "1.11.0", "expo-image-manipulator": "11.8.0", + "focus-trap-react": "^10.2.3", "htmlparser2": "^7.2.0", "idb-keyval": "^6.2.1", "jest-expo": "50.0.1", @@ -102,7 +103,7 @@ "react-native-linear-gradient": "^2.8.1", "react-native-localize": "^2.2.6", "react-native-modal": "^13.0.0", - "react-native-onyx": "2.0.32", + "react-native-onyx": "2.0.48", "react-native-pager-view": "6.2.3", "react-native-pdf": "6.7.3", "react-native-performance": "^5.1.0", @@ -207,7 +208,7 @@ "electron-builder": "24.13.2", "eslint": "^7.6.0", "eslint-config-airbnb-typescript": "^17.1.0", - "eslint-config-expensify": "^2.0.50", + "eslint-config-expensify": "^2.0.51", "eslint-config-prettier": "^8.8.0", "eslint-plugin-import": "^2.29.1", "eslint-plugin-jest": "^24.1.0", @@ -240,7 +241,7 @@ "ts-jest": "^29.1.2", "ts-node": "^10.9.2", "type-fest": "^4.10.2", - "typescript": "^5.3.2", + "typescript": "^5.4.5", "wait-port": "^0.2.9", "webpack": "^5.76.0", "webpack-bundle-analyzer": "^4.5.0", @@ -254,14 +255,6 @@ "npm": "10.7.0" } }, - "node_modules/@aashutoshrathi/word-wrap": { - "version": "1.2.6", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/@actions/core": { "version": "1.10.0", "dev": true, @@ -3557,10 +3550,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@eslint/js": { + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.0.tgz", + "integrity": "sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g==", + "dev": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, "node_modules/@expensify/react-native-live-markdown": { - "version": "0.1.82", - "resolved": "https://registry.npmjs.org/@expensify/react-native-live-markdown/-/react-native-live-markdown-0.1.82.tgz", - "integrity": "sha512-w/K2+0d1sAYvyLVpPv1ufDOTaj4y96Z362N3JDN+SDUmPQN2MvVGwsTL0ltzdw78yd62azFcQl6th7P6l62THQ==", + "version": "0.1.83", + "resolved": "https://registry.npmjs.org/@expensify/react-native-live-markdown/-/react-native-live-markdown-0.1.83.tgz", + "integrity": "sha512-xGn1P9FbFVueEF8BNKJJ4dQb0wPtsAvrrxND9pwVQT35ZL5cu1KZ4o6nzCqtesISPRB8Dw9Zx0ftIZy2uCQyzA==", "workspaces": [ "parser", "example", @@ -5681,6 +5683,19 @@ "node": ">=10.10.0" } }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, "node_modules/@humanwhocodes/object-schema": { "version": "1.2.1", "dev": true, @@ -7051,48 +7066,12 @@ "act-js": "bin/act" } }, - "node_modules/@kie/act-js/node_modules/@kie/mock-github": { - "version": "2.0.0", - "license": "SEE LICENSE IN LICENSE", - "dependencies": { - "@octokit/openapi-types-ghec": "^18.0.0", - "ajv": "^8.11.0", - "express": "^4.18.1", - "fast-glob": "^3.2.12", - "fs-extra": "^10.1.0", - "nock": "^13.2.7", - "simple-git": "^3.8.0", - "totalist": "^3.0.0" - } - }, - "node_modules/@kie/act-js/node_modules/@octokit/openapi-types-ghec": { - "version": "18.1.1", - "license": "MIT" - }, - "node_modules/@kie/act-js/node_modules/fs-extra": { - "version": "10.1.0", - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@kie/act-js/node_modules/totalist": { - "version": "3.0.1", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/@kie/mock-github": { - "version": "1.1.0", - "license": "SEE LICENSE IN LICENSE", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@kie/mock-github/-/mock-github-2.0.1.tgz", + "integrity": "sha512-G1FD/jg1KyW7a6NvKI4uEVJCK3eJnzXkh4Ikxn2is5tiNC980lavi8ak6bn1QEFEgpYcfM4DpZM3yHDfOmyLuQ==", "dependencies": { - "@octokit/openapi-types-ghec": "^14.0.0", + "@octokit/openapi-types-ghec": "^18.0.0", "ajv": "^8.11.0", "express": "^4.18.1", "fast-glob": "^3.2.12", @@ -7542,8 +7521,9 @@ "license": "MIT" }, "node_modules/@octokit/openapi-types-ghec": { - "version": "14.0.0", - "license": "MIT" + "version": "18.1.1", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types-ghec/-/openapi-types-ghec-18.1.1.tgz", + "integrity": "sha512-5Ri7FLYX4gJSdG+G0Q8QDca/gOLfkPN4YR2hkbVg6hEL+0N62MIsJPTyNaT9pGEXCLd1KbYV6Lh3T2ggsmyBJw==" }, "node_modules/@octokit/plugin-paginate-rest": { "version": "3.1.0", @@ -19370,14 +19350,16 @@ } }, "node_modules/eslint-config-expensify": { - "version": "2.0.50", - "resolved": "https://registry.npmjs.org/eslint-config-expensify/-/eslint-config-expensify-2.0.50.tgz", - "integrity": "sha512-I+OMkEprqEWlSCZGJBJxpt2Wg4HQ41/QqpKVfcADiQ3xJ76bZ1mBueqz6DR4jfph1xC6XVRl4dqGNlwbeU/2Rg==", + "version": "2.0.51", + "resolved": "https://registry.npmjs.org/eslint-config-expensify/-/eslint-config-expensify-2.0.51.tgz", + "integrity": "sha512-qEUPCI9vsAi5c5E6zM4QEYal13hIRHFvonf4U/x0JI4ceMdAejOoq/Zvt9r1ZwKT1RmA8eRoGWWIQ/4O/9hJPg==", "dev": true, "dependencies": { "@lwc/eslint-plugin-lwc": "^1.7.2", + "@typescript-eslint/parser": "^7.12.0", + "@typescript-eslint/utils": "^7.12.0", "babel-eslint": "^10.1.0", - "eslint": "^7.32.0", + "eslint": "^8.56.0", "eslint-config-airbnb": "19.0.4", "eslint-config-airbnb-base": "15.0.0", "eslint-plugin-es": "^4.1.0", @@ -19390,6 +19372,468 @@ "underscore": "^1.13.6" } }, + "node_modules/eslint-config-expensify/node_modules/@eslint/eslintrc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", + "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "dev": true, + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^9.6.0", + "globals": "^13.19.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-config-expensify/node_modules/@humanwhocodes/config-array": { + "version": "0.11.14", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", + "integrity": "sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==", + "dev": true, + "dependencies": { + "@humanwhocodes/object-schema": "^2.0.2", + "debug": "^4.3.1", + "minimatch": "^3.0.5" + }, + "engines": { + "node": ">=10.10.0" + } + }, + "node_modules/eslint-config-expensify/node_modules/@humanwhocodes/object-schema": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", + "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", + "dev": true + }, + "node_modules/eslint-config-expensify/node_modules/@typescript-eslint/parser": { + "version": "7.12.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.12.0.tgz", + "integrity": "sha512-dm/J2UDY3oV3TKius2OUZIFHsomQmpHtsV0FTh1WO8EKgHLQ1QCADUqscPgTpU+ih1e21FQSRjXckHn3txn6kQ==", + "dev": true, + "dependencies": { + "@typescript-eslint/scope-manager": "7.12.0", + "@typescript-eslint/types": "7.12.0", + "@typescript-eslint/typescript-estree": "7.12.0", + "@typescript-eslint/visitor-keys": "7.12.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.56.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/eslint-config-expensify/node_modules/@typescript-eslint/scope-manager": { + "version": "7.12.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.12.0.tgz", + "integrity": "sha512-itF1pTnN6F3unPak+kutH9raIkL3lhH1YRPGgt7QQOh43DQKVJXmWkpb+vpc/TiDHs6RSd9CTbDsc/Y+Ygq7kg==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "7.12.0", + "@typescript-eslint/visitor-keys": "7.12.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/eslint-config-expensify/node_modules/@typescript-eslint/types": { + "version": "7.12.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.12.0.tgz", + "integrity": "sha512-o+0Te6eWp2ppKY3mLCU+YA9pVJxhUJE15FV7kxuD9jgwIAa+w/ycGJBMrYDTpVGUM/tgpa9SeMOugSabWFq7bg==", + "dev": true, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/eslint-config-expensify/node_modules/@typescript-eslint/typescript-estree": { + "version": "7.12.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.12.0.tgz", + "integrity": "sha512-5bwqLsWBULv1h6pn7cMW5dXX/Y2amRqLaKqsASVwbBHMZSnHqE/HN4vT4fE0aFsiwxYvr98kqOWh1a8ZKXalCQ==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "7.12.0", + "@typescript-eslint/visitor-keys": "7.12.0", + "debug": "^4.3.4", + "globby": "^11.1.0", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/eslint-config-expensify/node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.4.tgz", + "integrity": "sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/eslint-config-expensify/node_modules/@typescript-eslint/utils": { + "version": "7.12.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.12.0.tgz", + "integrity": "sha512-Y6hhwxwDx41HNpjuYswYp6gDbkiZ8Hin9Bf5aJQn1bpTs3afYY4GX+MPYxma8jtoIV2GRwTM/UJm/2uGCVv+DQ==", + "dev": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.4.0", + "@typescript-eslint/scope-manager": "7.12.0", + "@typescript-eslint/types": "7.12.0", + "@typescript-eslint/typescript-estree": "7.12.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.56.0" + } + }, + "node_modules/eslint-config-expensify/node_modules/@typescript-eslint/visitor-keys": { + "version": "7.12.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.12.0.tgz", + "integrity": "sha512-uZk7DevrQLL3vSnfFl5bj4sL75qC9D6EdjemIdbtkuUmIheWpuiiylSY01JxJE7+zGrOWDZrp1WxOuDntvKrHQ==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "7.12.0", + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/eslint-config-expensify/node_modules/acorn": { + "version": "8.11.3", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz", + "integrity": "sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==", + "dev": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/eslint-config-expensify/node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/eslint-config-expensify/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/eslint-config-expensify/node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true + }, + "node_modules/eslint-config-expensify/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/eslint-config-expensify/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/eslint-config-expensify/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/eslint-config-expensify/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/eslint-config-expensify/node_modules/eslint": { + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.0.tgz", + "integrity": "sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==", + "dev": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.6.1", + "@eslint/eslintrc": "^2.1.4", + "@eslint/js": "8.57.0", + "@humanwhocodes/config-array": "^0.11.14", + "@humanwhocodes/module-importer": "^1.0.1", + "@nodelib/fs.walk": "^1.2.8", + "@ungap/structured-clone": "^1.2.0", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "doctrine": "^3.0.0", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.3", + "espree": "^9.6.1", + "esquery": "^1.4.2", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^6.0.1", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "globals": "^13.19.0", + "graphemer": "^1.4.0", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "is-path-inside": "^3.0.3", + "js-yaml": "^4.1.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3", + "strip-ansi": "^6.0.1", + "text-table": "^0.2.0" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-config-expensify/node_modules/eslint-scope": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", + "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "dev": true, + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-config-expensify/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-config-expensify/node_modules/espree": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "dev": true, + "dependencies": { + "acorn": "^8.9.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-config-expensify/node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/eslint-config-expensify/node_modules/globals": { + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "dev": true, + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint-config-expensify/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/eslint-config-expensify/node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/eslint-config-expensify/node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/eslint-config-expensify/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, + "node_modules/eslint-config-expensify/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/eslint-config-expensify/node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/eslint-config-prettier": { "version": "8.10.0", "dev": true, @@ -20025,22 +20469,6 @@ "dev": true, "license": "MIT" }, - "node_modules/eslint/node_modules/optionator": { - "version": "0.9.3", - "dev": true, - "license": "MIT", - "dependencies": { - "@aashutoshrathi/word-wrap": "^1.2.3", - "deep-is": "^0.1.3", - "fast-levenshtein": "^2.0.6", - "levn": "^0.4.1", - "prelude-ls": "^1.2.1", - "type-check": "^0.4.0" - }, - "engines": { - "node": ">= 0.8.0" - } - }, "node_modules/eslint/node_modules/supports-color": { "version": "7.2.0", "dev": true, @@ -21287,6 +21715,28 @@ "node": ">=0.4.0" } }, + "node_modules/focus-trap": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-7.5.4.tgz", + "integrity": "sha512-N7kHdlgsO/v+iD/dMoJKtsSqs5Dz/dXZVebRgJw23LDk+jMi/974zyiOYDziY2JPp8xivq9BmUGwIJMiuSBi7w==", + "dependencies": { + "tabbable": "^6.2.0" + } + }, + "node_modules/focus-trap-react": { + "version": "10.2.3", + "resolved": "https://registry.npmjs.org/focus-trap-react/-/focus-trap-react-10.2.3.tgz", + "integrity": "sha512-YXBpFu/hIeSu6NnmV2xlXzOYxuWkoOtar9jzgp3lOmjWLWY59C/b8DtDHEAV4SPU07Nd/t+nS/SBNGkhUBFmEw==", + "dependencies": { + "focus-trap": "^7.5.4", + "tabbable": "^6.2.0" + }, + "peerDependencies": { + "prop-types": "^15.8.1", + "react": ">=16.3.0", + "react-dom": ">=16.3.0" + } + }, "node_modules/follow-redirects": { "version": "1.15.5", "funding": [ @@ -29497,6 +29947,23 @@ "opener": "bin/opener-bin.js" } }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/ora": { "version": "5.4.1", "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", @@ -31472,17 +31939,17 @@ } }, "node_modules/react-native-onyx": { - "version": "2.0.32", - "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-2.0.32.tgz", - "integrity": "sha512-tB9wqMJGTLOYfrfplRP+9aq5JdD8w/hV/OZsMAVH+ewbE1zLY8OymUsAsIFdF1v+cB8HhehP569JVLZmhm6bsg==", + "version": "2.0.48", + "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-2.0.48.tgz", + "integrity": "sha512-qJQTWMzhLD7zy5/9vBZJSlb3//fYVx3obTdsw1tXZDVOZXUcBmd6evA2tzGe5KT8H2sIbvFR1UyvwE03oOqYYg==", "dependencies": { "ascii-table": "0.0.9", "fast-equals": "^4.0.3", "underscore": "^1.13.6" }, "engines": { - "node": ">=20.10.0", - "npm": ">=10.2.3" + "node": ">=20.14.0", + "npm": ">=10.7.0" }, "peerDependencies": { "idb-keyval": "^6.2.1", @@ -35100,6 +35567,11 @@ "version": "3.2.4", "license": "MIT" }, + "node_modules/tabbable": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz", + "integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==" + }, "node_modules/table": { "version": "6.8.1", "dev": true, @@ -35760,11 +36232,12 @@ } }, "node_modules/ts-api-utils": { - "version": "1.0.1", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.3.0.tgz", + "integrity": "sha512-UQMIo7pb8WRomKR1/+MFVLTroIvDVtMX3K6OUir8ynLyzB8Jeriont2bTAtmNPa1ekAgN7YPDyf6V+ygrdU+eQ==", "dev": true, - "license": "MIT", "engines": { - "node": ">=16.13.0" + "node": ">=16" }, "peerDependencies": { "typescript": ">=4.2.0" @@ -36081,9 +36554,10 @@ } }, "node_modules/typescript": { - "version": "5.3.3", + "version": "5.4.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz", + "integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==", "devOptional": true, - "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -37740,6 +38214,15 @@ "version": "4.0.15", "license": "MIT" }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/wordwrap": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", diff --git a/package.json b/package.json index ee1ad32a0067..246890592c5d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "1.4.80-16", + "version": "1.4.82-4", "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.", @@ -65,7 +65,7 @@ "@babel/plugin-proposal-private-methods": "^7.18.6", "@babel/plugin-proposal-private-property-in-object": "^7.21.11", "@dotlottie/react-player": "^1.6.3", - "@expensify/react-native-live-markdown": "0.1.82", + "@expensify/react-native-live-markdown": "0.1.83", "@expo/metro-runtime": "~3.1.1", "@formatjs/intl-datetimeformat": "^6.10.0", "@formatjs/intl-listformat": "^7.2.2", @@ -78,7 +78,7 @@ "@gorhom/portal": "^1.0.14", "@invertase/react-native-apple-authentication": "^2.2.2", "@kie/act-js": "^2.6.0", - "@kie/mock-github": "^1.0.0", + "@kie/mock-github": "2.0.1", "@onfido/react-native-sdk": "10.6.0", "@react-native-camera-roll/camera-roll": "7.4.0", "@react-native-clipboard/clipboard": "^1.13.2", @@ -116,6 +116,7 @@ "expo-av": "~13.10.4", "expo-image": "1.11.0", "expo-image-manipulator": "11.8.0", + "focus-trap-react": "^10.2.3", "htmlparser2": "^7.2.0", "idb-keyval": "^6.2.1", "jest-expo": "50.0.1", @@ -154,7 +155,7 @@ "react-native-linear-gradient": "^2.8.1", "react-native-localize": "^2.2.6", "react-native-modal": "^13.0.0", - "react-native-onyx": "2.0.32", + "react-native-onyx": "2.0.48", "react-native-pager-view": "6.2.3", "react-native-pdf": "6.7.3", "react-native-performance": "^5.1.0", @@ -259,7 +260,7 @@ "electron-builder": "24.13.2", "eslint": "^7.6.0", "eslint-config-airbnb-typescript": "^17.1.0", - "eslint-config-expensify": "^2.0.50", + "eslint-config-expensify": "^2.0.51", "eslint-config-prettier": "^8.8.0", "eslint-plugin-import": "^2.29.1", "eslint-plugin-jest": "^24.1.0", @@ -292,7 +293,7 @@ "ts-jest": "^29.1.2", "ts-node": "^10.9.2", "type-fest": "^4.10.2", - "typescript": "^5.3.2", + "typescript": "^5.4.5", "wait-port": "^0.2.9", "webpack": "^5.76.0", "webpack-bundle-analyzer": "^4.5.0", diff --git a/patches/@react-navigation+native+6.1.12.patch b/patches/@react-navigation+native+6.1.12.patch index d53f8677d225..0ac57a865dfd 100644 --- a/patches/@react-navigation+native+6.1.12.patch +++ b/patches/@react-navigation+native+6.1.12.patch @@ -1,5 +1,5 @@ diff --git a/node_modules/@react-navigation/native/lib/module/createMemoryHistory.js b/node_modules/@react-navigation/native/lib/module/createMemoryHistory.js -index 16fdbef..e660dd6 100644 +index 16fdbef..231a520 100644 --- a/node_modules/@react-navigation/native/lib/module/createMemoryHistory.js +++ b/node_modules/@react-navigation/native/lib/module/createMemoryHistory.js @@ -1,8 +1,23 @@ @@ -68,7 +68,7 @@ index 16fdbef..e660dd6 100644 // Need to keep the hash part of the path if there was no previous history entry // or the previous history entry had the same path - let pathWithHash = path; -+ let pathWithHash = path.replace(/(\/{2,})|(\/$)/g, (match, p1) => (p1 ? '/' : '')); ++ let pathWithHash = path.replace(/(\/{2,})/g, '/'); if (!items.length || items.findIndex(item => item.id === id) < 0) { // There are two scenarios for creating an array with only one history record: // - When loaded id not found in the items array, this function by default will replace diff --git a/patches/react-native-web+0.19.12+006+remove focus trap from modal.patch b/patches/react-native-web+0.19.12+006+remove focus trap from modal.patch new file mode 100644 index 000000000000..14dbc88b0b1c --- /dev/null +++ b/patches/react-native-web+0.19.12+006+remove focus trap from modal.patch @@ -0,0 +1,20 @@ +diff --git a/node_modules/react-native-web/dist/exports/Modal/index.js b/node_modules/react-native-web/dist/exports/Modal/index.js +index d5df021..e2c46cf 100644 +--- a/node_modules/react-native-web/dist/exports/Modal/index.js ++++ b/node_modules/react-native-web/dist/exports/Modal/index.js +@@ -86,13 +86,11 @@ var Modal = /*#__PURE__*/React.forwardRef((props, forwardedRef) => { + onDismiss: onDismissCallback, + onShow: onShowCallback, + visible: visible +- }, /*#__PURE__*/React.createElement(ModalFocusTrap, { +- active: isActive + }, /*#__PURE__*/React.createElement(ModalContent, _extends({}, rest, { + active: isActive, + onRequestClose: onRequestClose, + ref: forwardedRef, + transparent: transparent +- }), children)))); ++ }), children))); + }); + export default Modal; +\ No newline at end of file diff --git a/patches/react-pdf+7.7.1.patch b/patches/react-pdf+7.7.3.patch similarity index 93% rename from patches/react-pdf+7.7.1.patch rename to patches/react-pdf+7.7.3.patch index f6ec8d8c1685..5b1b3ebb6f6e 100644 --- a/patches/react-pdf+7.7.1.patch +++ b/patches/react-pdf+7.7.3.patch @@ -1,5 +1,5 @@ diff --git a/node_modules/react-pdf/dist/esm/Document.js b/node_modules/react-pdf/dist/esm/Document.js -index 493ff15..8d5e734 100644 +index b1c5a81..569769e 100644 --- a/node_modules/react-pdf/dist/esm/Document.js +++ b/node_modules/react-pdf/dist/esm/Document.js @@ -261,6 +261,7 @@ const Document = forwardRef(function Document(_a, ref) { diff --git a/src/App.tsx b/src/App.tsx index 6316fa80fba1..9eda57816e9d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -6,7 +6,7 @@ import {PickerStateProvider} from 'react-native-picker-select'; import {SafeAreaProvider} from 'react-native-safe-area-context'; import '../wdyr'; import ActiveElementRoleProvider from './components/ActiveElementRoleProvider'; -import ActiveWorkspaceContextProvider from './components/ActiveWorkspace/ActiveWorkspaceProvider'; +import ActiveWorkspaceContextProvider from './components/ActiveWorkspaceProvider'; import ColorSchemeWrapper from './components/ColorSchemeWrapper'; import ComposeProviders from './components/ComposeProviders'; import CustomStatusBarAndBackground from './components/CustomStatusBarAndBackground'; diff --git a/src/CONST.ts b/src/CONST.ts index b0e3ab8c3af4..6a936bc97087 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -569,6 +569,7 @@ const CONST = { LICENSES_URL: `${USE_EXPENSIFY_URL}/licenses`, ACH_TERMS_URL: `${USE_EXPENSIFY_URL}/achterms`, WALLET_AGREEMENT_URL: `${USE_EXPENSIFY_URL}/walletagreement`, + BANCORP_WALLET_AGREEMENT_URL: `${USE_EXPENSIFY_URL}/bancorp-bank-wallet-terms-of-service`, HELP_LINK_URL: `${USE_EXPENSIFY_URL}/usa-patriot-act`, ELECTRONIC_DISCLOSURES_URL: `${USE_EXPENSIFY_URL}/esignagreement`, GITHUB_RELEASE_URL: 'https://api.github.com/repos/expensify/app/releases/latest', @@ -597,7 +598,6 @@ const CONST = { ADMIN_POLICIES_URL: 'admin_policies', ADMIN_DOMAINS_URL: 'admin_domains', INBOX: 'inbox', - DISMMISSED_REASON: '?dismissedReason=missingFeatures', }, SIGN_IN_FORM_WIDTH: 300, @@ -1429,6 +1429,7 @@ const CONST = { }, STEP: { // In the order they appear in the Wallet flow + ADD_BANK_ACCOUNT: 'AddBankAccountStep', ADDITIONAL_DETAILS: 'AdditionalDetailsStep', ADDITIONAL_DETAILS_KBA: 'AdditionalDetailsKBAStep', ONFIDO: 'OnfidoStep', @@ -1465,6 +1466,7 @@ const CONST = { CONCIERGE: 'CONCIERGE_NAVIGATE', }, MTL_WALLET_PROGRAM_ID: '760', + BANCORP_WALLET_PROGRAM_ID: '660', PROGRAM_ISSUERS: { EXPENSIFY_PAYMENTS: 'Expensify Payments LLC', BANCORP_BANK: 'The Bancorp Bank', @@ -1829,7 +1831,7 @@ const CONST = { NAME_DISTANCE: 'Distance', DISTANCE_UNIT_MILES: 'mi', DISTANCE_UNIT_KILOMETERS: 'km', - MILEAGE_IRS_RATE: 0.655, + MILEAGE_IRS_RATE: 0.67, DEFAULT_RATE: 'Default Rate', RATE_DECIMALS: 3, FAKE_P2P_ID: '_FAKE_P2P_ID_', @@ -3394,6 +3396,11 @@ const CONST = { * @deprecated Please stop using the accessibilityRole prop and use the role prop instead. */ IMAGE: 'image', + + /** + * @deprecated Please stop using the accessibilityRole prop and use the role prop instead. + */ + TEXTBOX: 'textbox', }, /** * Acceptable values for the `role` attribute on react native components. @@ -3456,6 +3463,8 @@ const CONST = { TIMER: 'timer', /** Use for toolbars containing action buttons or components. */ TOOLBAR: 'toolbar', + /** Use for navigation elements */ + NAVIGATION: 'navigation', }, TRANSLATION_KEYS: { ATTACHMENT: 'common.attachment', @@ -4753,6 +4762,7 @@ const CONST = { SESSION_STORAGE_KEYS: { INITIAL_URL: 'INITIAL_URL', + ACTIVE_WORKSPACE_ID: 'ACTIVE_WORKSPACE_ID', }, RESERVATION_TYPE: { @@ -4815,6 +4825,15 @@ const CONST = { }, SUBSCRIPTION_SIZE_LIMIT: 20000, + + PAYMENT_CARD_CURRENCY: { + USD: 'USD', + AUD: 'AUD', + GBP: 'GBP', + NZD: 'NZD', + }, + + SUBSCRIPTION_PRICE_FACTOR: 2, SUBSCRIPTION_POSSIBLE_COST_SAVINGS: { COLLECT_PLAN: 10, CONTROL_PLAN: 18, @@ -4837,6 +4856,8 @@ const CONST = { TRANSLATION_KEY: 'feedbackSurvey.businessClosing', }, }, + + EXCLUDE_FROM_LAST_VISITED_PATH: [SCREENS.NOT_FOUND, SCREENS.SAML_SIGN_IN, SCREENS.VALIDATE_LOGIN] as string[], } as const; type Country = keyof typeof CONST.ALL_COUNTRIES; @@ -4845,6 +4866,8 @@ type IOUType = ValueOf; type IOUAction = ValueOf; type IOURequestType = ValueOf; -export type {Country, IOUAction, IOUType, RateAndUnit, OnboardingPurposeType, IOURequestType}; +type SubscriptionType = ValueOf; + +export type {Country, IOUAction, IOUType, RateAndUnit, OnboardingPurposeType, IOURequestType, SubscriptionType}; export default CONST; diff --git a/src/ROUTES.ts b/src/ROUTES.ts index ed20d388bb87..eb3b439ea1ff 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -140,12 +140,7 @@ const ROUTES = { }, SETTINGS_ADD_DEBIT_CARD: 'settings/wallet/add-debit-card', SETTINGS_ADD_BANK_ACCOUNT: 'settings/wallet/add-bank-account', - SETTINGS_ADD_BANK_ACCOUNT_REFACTOR: 'settings/wallet/add-bank-account-refactor', SETTINGS_ENABLE_PAYMENTS: 'settings/wallet/enable-payments', - // TODO: Added temporarily for testing purposes, remove after refactor - https://github.com/Expensify/App/issues/36648 - SETTINGS_ENABLE_PAYMENTS_REFACTOR: 'settings/wallet/enable-payments-refactor', - // TODO: Added temporarily for testing purposes, remove after refactor - https://github.com/Expensify/App/issues/36648 - SETTINGS_ENABLE_PAYMENTS_TEMPORARY_TERMS: 'settings/wallet/enable-payments-temporary-terms', SETTINGS_WALLET_CARD_DIGITAL_DETAILS_UPDATE_ADDRESS: { route: 'settings/wallet/card/:domain/digital-details/update-address', getRoute: (domain: string) => `settings/wallet/card/${domain}/digital-details/update-address` as const, diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 9f7277a0ad0f..f884cca94ef5 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -42,7 +42,6 @@ const SCREENS = { APP_DOWNLOAD_LINKS: 'Settings_App_Download_Links', ADD_DEBIT_CARD: 'Settings_Add_Debit_Card', ADD_BANK_ACCOUNT: 'Settings_Add_Bank_Account', - ADD_BANK_ACCOUNT_REFACTOR: 'Settings_Add_Bank_Account_Refactor', CLOSE: 'Settings_Close', TWO_FACTOR_AUTH: 'Settings_TwoFactorAuth', REPORT_CARD_LOST_OR_DAMAGED: 'Settings_ReportCardLostOrDamaged', @@ -89,10 +88,6 @@ const SCREENS = { TRANSFER_BALANCE: 'Settings_Wallet_Transfer_Balance', CHOOSE_TRANSFER_ACCOUNT: 'Settings_Wallet_Choose_Transfer_Account', ENABLE_PAYMENTS: 'Settings_Wallet_EnablePayments', - // TODO: Added temporarily for testing purposes, remove after refactor - https://github.com/Expensify/App/issues/36648 - ENABLE_PAYMENTS_REFACTOR: 'Settings_Wallet_EnablePayments_Refactor', - // TODO: Added temporarily for testing purposes, remove after refactor - https://github.com/Expensify/App/issues/36648 - ENABLE_PAYMENTS_TEMPORARY_TERMS: 'Settings_Wallet_EnablePayments_Temporary_Terms', CARD_ACTIVATE: 'Settings_Wallet_Card_Activate', REPORT_VIRTUAL_CARD_FRAUD: 'Settings_Wallet_ReportVirtualCardFraud', CARDS_DIGITAL_DETAILS_UPDATE_ADDRESS: 'Settings_Wallet_Cards_Digital_Details_Update_Address', diff --git a/src/TIMEZONES.ts b/src/TIMEZONES.ts index 238563134872..69ef89e7467e 100644 --- a/src/TIMEZONES.ts +++ b/src/TIMEZONES.ts @@ -1,5 +1,7 @@ /* eslint-disable @typescript-eslint/naming-convention */ // All timezones were taken from: https://raw.githubusercontent.com/leon-do/Timezones/main/timezone.json +import type {TupleToUnion} from 'type-fest'; + const TIMEZONES = [ 'Africa/Abidjan', 'Africa/Accra', @@ -425,7 +427,7 @@ const TIMEZONES = [ * The timezones supported in browser and on native devices differ, so we must map each timezone to its supported equivalent. * Data sourced from https://en.wikipedia.org/wiki/List_of_tz_database_time_zones */ -const timezoneBackwardMap: Record = { +const timezoneBackwardMap: Record> = { 'Africa/Asmera': 'Africa/Nairobi', 'Africa/Timbuktu': 'Africa/Abidjan', 'America/Argentina/ComodRivadavia': 'America/Argentina/Catamarca', diff --git a/src/components/ActiveWorkspace/ActiveWorkspaceProvider.tsx b/src/components/ActiveWorkspaceProvider/index.tsx similarity index 80% rename from src/components/ActiveWorkspace/ActiveWorkspaceProvider.tsx rename to src/components/ActiveWorkspaceProvider/index.tsx index 884b9a2a2d95..bc7260cdf10b 100644 --- a/src/components/ActiveWorkspace/ActiveWorkspaceProvider.tsx +++ b/src/components/ActiveWorkspaceProvider/index.tsx @@ -1,6 +1,6 @@ import React, {useMemo, useState} from 'react'; +import ActiveWorkspaceContext from '@components/ActiveWorkspace/ActiveWorkspaceContext'; import type ChildrenProps from '@src/types/utils/ChildrenProps'; -import ActiveWorkspaceContext from './ActiveWorkspaceContext'; function ActiveWorkspaceContextProvider({children}: ChildrenProps) { const [activeWorkspaceID, setActiveWorkspaceID] = useState(undefined); @@ -10,7 +10,7 @@ function ActiveWorkspaceContextProvider({children}: ChildrenProps) { activeWorkspaceID, setActiveWorkspaceID, }), - [activeWorkspaceID], + [activeWorkspaceID, setActiveWorkspaceID], ); return {children}; diff --git a/src/components/ActiveWorkspaceProvider/index.website.tsx b/src/components/ActiveWorkspaceProvider/index.website.tsx new file mode 100644 index 000000000000..82e46d70f896 --- /dev/null +++ b/src/components/ActiveWorkspaceProvider/index.website.tsx @@ -0,0 +1,29 @@ +import React, {useCallback, useMemo, useState} from 'react'; +import ActiveWorkspaceContext from '@components/ActiveWorkspace/ActiveWorkspaceContext'; +import CONST from '@src/CONST'; +import type ChildrenProps from '@src/types/utils/ChildrenProps'; + +function ActiveWorkspaceContextProvider({children}: ChildrenProps) { + const [activeWorkspaceID, updateActiveWorkspaceID] = useState(undefined); + + const setActiveWorkspaceID = useCallback((workspaceID: string | undefined) => { + updateActiveWorkspaceID(workspaceID); + if (workspaceID && sessionStorage) { + sessionStorage?.setItem(CONST.SESSION_STORAGE_KEYS.ACTIVE_WORKSPACE_ID, workspaceID); + } else { + sessionStorage?.removeItem(CONST.SESSION_STORAGE_KEYS.ACTIVE_WORKSPACE_ID); + } + }, []); + + const value = useMemo( + () => ({ + activeWorkspaceID, + setActiveWorkspaceID, + }), + [activeWorkspaceID, setActiveWorkspaceID], + ); + + return {children}; +} + +export default ActiveWorkspaceContextProvider; diff --git a/src/components/AddPlaidBankAccount.tsx b/src/components/AddPlaidBankAccount.tsx index 366f14ec9780..eaefa3c5581c 100644 --- a/src/components/AddPlaidBankAccount.tsx +++ b/src/components/AddPlaidBankAccount.tsx @@ -18,7 +18,6 @@ import FullPageOfflineBlockingView from './BlockingViews/FullPageOfflineBlocking import FormHelpMessage from './FormHelpMessage'; import Icon from './Icon'; import getBankIcon from './Icon/BankIcons'; -import Picker from './Picker'; import PlaidLink from './PlaidLink'; import RadioButtons from './RadioButtons'; import Text from './Text'; @@ -59,9 +58,6 @@ type AddPlaidBankAccountProps = AddPlaidBankAccountOnyxProps & { /** Are we adding a withdrawal account? */ allowDebit?: boolean; - /** Is displayed in new VBBA */ - isDisplayedInNewVBBA?: boolean; - /** Is displayed in new enable wallet flow */ isDisplayedInWalletFlow?: boolean; @@ -84,7 +80,6 @@ function AddPlaidBankAccount({ bankAccountID = 0, allowDebit = false, isPlaidDisabled, - isDisplayedInNewVBBA = false, errorText = '', onInputChange = () => {}, isDisplayedInWalletFlow = false, @@ -259,62 +254,32 @@ function AddPlaidBankAccount({ return {renderPlaidLink()}; } - if (isDisplayedInNewVBBA || isDisplayedInWalletFlow) { - return ( - - {translate(isDisplayedInWalletFlow ? 'walletPage.chooseYourBankAccount' : 'bankAccount.chooseAnAccount')} - {!!text && {text}} - - - - {bankName} - {selectedPlaidAccountMask.length > 0 && ( - {`${translate('bankAccount.accountEnding')} ${selectedPlaidAccountMask}`} - )} - - - {`${translate('bankAccount.chooseAnAccountBelow')}:`} - - - - ); - } - - // Plaid bank accounts view return ( - {!!text && {text}} - + {translate(isDisplayedInWalletFlow ? 'walletPage.chooseYourBankAccount' : 'bankAccount.chooseAnAccount')} + {!!text && {text}} + - {bankName} - - - + + {bankName} + {selectedPlaidAccountMask.length > 0 && ( + {`${translate('bankAccount.accountEnding')} ${selectedPlaidAccountMask}`} + )} + + {`${translate('bankAccount.chooseAnAccountBelow')}:`} + + ); } diff --git a/src/components/AddressForm.tsx b/src/components/AddressForm.tsx index 9ad4643e834a..296ecce7d092 100644 --- a/src/components/AddressForm.tsx +++ b/src/components/AddressForm.tsx @@ -4,7 +4,6 @@ import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import * as ErrorUtils from '@libs/ErrorUtils'; import type {MaybePhraseKey} from '@libs/Localize'; -import Navigation from '@libs/Navigation/Navigation'; import * as ValidationUtils from '@libs/ValidationUtils'; import CONST from '@src/CONST'; import type {Country} from '@src/CONST'; @@ -149,8 +148,6 @@ function AddressForm({ label={translate('common.addressLine', {lineNumber: 1})} onValueChange={(data: unknown, key: unknown) => { onAddressChanged(data, key); - // This enforces the country selector to use the country from address instead of the country from URL - Navigation.setParams({country: undefined}); }} defaultValue={street1} renamedInputKeys={{ diff --git a/src/components/AmountForm.tsx b/src/components/AmountForm.tsx index a102b715d526..3319a28c58b9 100644 --- a/src/components/AmountForm.tsx +++ b/src/components/AmountForm.tsx @@ -12,6 +12,7 @@ import * as MoneyRequestUtils from '@libs/MoneyRequestUtils'; import CONST from '@src/CONST'; import BigNumberPad from './BigNumberPad'; import FormHelpMessage from './FormHelpMessage'; +import isTextInputFocused from './TextInput/BaseTextInput/isTextInputFocused'; import type {BaseTextInputProps, BaseTextInputRef} from './TextInput/BaseTextInput/types'; import TextInputWithCurrencySymbol from './TextInputWithCurrencySymbol'; import type TextInputWithCurrencySymbolProps from './TextInputWithCurrencySymbol/types'; @@ -94,7 +95,7 @@ function AmountForm( if (!textInput.current) { return; } - if (!textInput.current.isFocused()) { + if (!isTextInputFocused(textInput)) { textInput.current.focus(); } }; @@ -143,7 +144,7 @@ function AmountForm( */ const updateAmountNumberPad = useCallback( (key: string) => { - if (shouldUpdateSelection && !textInput.current?.isFocused()) { + if (shouldUpdateSelection && !isTextInputFocused(textInput)) { textInput.current?.focus(); } // Backspace button is pressed @@ -168,7 +169,7 @@ function AmountForm( */ const updateLongPressHandlerState = useCallback((value: boolean) => { setShouldUpdateSelection(!value); - if (!value && !textInput.current?.isFocused()) { + if (!value && !isTextInputFocused(textInput)) { textInput.current?.focus(); } }, []); diff --git a/src/components/AttachmentModal.tsx b/src/components/AttachmentModal.tsx index 54a073e30567..f09b7c217ac5 100644 --- a/src/components/AttachmentModal.tsx +++ b/src/components/AttachmentModal.tsx @@ -620,7 +620,6 @@ export default withOnyx({ const transactionID = parentReportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.IOU ? parentReportAction?.originalMessage.IOUTransactionID ?? '0' : '0'; return `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`; }, - initWithStoredValues: false, }, })(memo(AttachmentModal)); diff --git a/src/components/AttachmentPicker/index.native.tsx b/src/components/AttachmentPicker/index.native.tsx index 559d5b24403a..154fcf838c86 100644 --- a/src/components/AttachmentPicker/index.native.tsx +++ b/src/components/AttachmentPicker/index.native.tsx @@ -5,7 +5,7 @@ import RNFetchBlob from 'react-native-blob-util'; import RNDocumentPicker from 'react-native-document-picker'; import type {DocumentPickerOptions, DocumentPickerResponse} from 'react-native-document-picker'; import {launchImageLibrary} from 'react-native-image-picker'; -import type {Asset, Callback, CameraOptions, ImageLibraryOptions, ImagePickerResponse} from 'react-native-image-picker'; +import type {Asset, Callback, CameraOptions, ImagePickerResponse} from 'react-native-image-picker'; import ImageSize from 'react-native-image-size'; import type {FileObject, ImagePickerResponse as FileResponse} from '@components/AttachmentModal'; import * as Expensicons from '@components/Icon/Expensicons'; @@ -41,12 +41,11 @@ type Item = { * See https://github.com/react-native-image-picker/react-native-image-picker/#options * for ImagePicker configuration options */ -const imagePickerOptions: Partial = { +const imagePickerOptions = { includeBase64: false, saveToPhotos: false, selectionLimit: 1, includeExtra: false, - assetRepresentationMode: 'current', }; /** diff --git a/src/components/AvatarWithDisplayName.tsx b/src/components/AvatarWithDisplayName.tsx index 9c7e26dacb6b..61a07a50736b 100644 --- a/src/components/AvatarWithDisplayName.tsx +++ b/src/components/AvatarWithDisplayName.tsx @@ -1,6 +1,6 @@ import React, {useCallback, useEffect, useRef} from 'react'; import {View} from 'react-native'; -import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; +import type {OnyxEntry} from 'react-native-onyx'; import {withOnyx} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; import useStyleUtils from '@hooks/useStyleUtils'; @@ -12,7 +12,7 @@ import * as ReportUtils from '@libs/ReportUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import type {PersonalDetails, Policy, Report, ReportActions} from '@src/types/onyx'; +import type {PersonalDetails, PersonalDetailsList, Policy, Report, ReportActions} from '@src/types/onyx'; import CaretWrapper from './CaretWrapper'; import DisplayNames from './DisplayNames'; import MultipleAvatars from './MultipleAvatars'; @@ -26,7 +26,7 @@ type AvatarWithDisplayNamePropsWithOnyx = { parentReportActions: OnyxEntry; /** Personal details of all users */ - personalDetails: OnyxCollection; + personalDetails: OnyxEntry; }; type AvatarWithDisplayNameProps = AvatarWithDisplayNamePropsWithOnyx & { diff --git a/src/components/Breadcrumbs.tsx b/src/components/Breadcrumbs.tsx index e5eb09691eba..e641a0c2218a 100644 --- a/src/components/Breadcrumbs.tsx +++ b/src/components/Breadcrumbs.tsx @@ -68,7 +68,7 @@ function Breadcrumbs({breadcrumbs, style}: BreadcrumbsProps) { / {secondaryBreadcrumb.text} diff --git a/src/components/Composer/index.native.tsx b/src/components/Composer/index.native.tsx index 2fbd635ed42e..9e66c0b20c99 100644 --- a/src/components/Composer/index.native.tsx +++ b/src/components/Composer/index.native.tsx @@ -1,3 +1,4 @@ +import type {MarkdownStyle} from '@expensify/react-native-live-markdown'; import type {ForwardedRef} from 'react'; import React, {useCallback, useEffect, useMemo, useRef} from 'react'; import type {TextInput} from 'react-native'; @@ -13,6 +14,9 @@ import updateIsFullComposerAvailable from '@libs/ComposerUtils/updateIsFullCompo import * as EmojiUtils from '@libs/EmojiUtils'; import type {ComposerProps} from './types'; +const excludeNoStyles: Array = []; +const excludeReportMentionStyle: Array = ['mentionReport']; + function Composer( { shouldClear = false, @@ -28,6 +32,7 @@ function Composer( // On Android the selection prop is required on the TextInput but this prop has issues on IOS selection, value, + isGroupPolicyReport = false, ...props }: ComposerProps, ref: ForwardedRef, @@ -36,7 +41,7 @@ function Composer( const {isFocused, shouldResetFocus} = useResetComposerFocus(textInput); const textContainsOnlyEmojis = useMemo(() => EmojiUtils.containsOnlyEmojis(value ?? ''), [value]); const theme = useTheme(); - const markdownStyle = useMarkdownStyle(value); + const markdownStyle = useMarkdownStyle(value, !isGroupPolicyReport ? excludeReportMentionStyle : excludeNoStyles); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); diff --git a/src/components/Composer/index.tsx b/src/components/Composer/index.tsx index 14762b2d4bc1..5bd8aa9175d3 100755 --- a/src/components/Composer/index.tsx +++ b/src/components/Composer/index.tsx @@ -1,3 +1,4 @@ +import type {MarkdownStyle} from '@expensify/react-native-live-markdown'; import lodashDebounce from 'lodash/debounce'; import type {BaseSyntheticEvent, ForwardedRef} from 'react'; import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; @@ -46,6 +47,9 @@ const getNextChars = (inputString: string, cursorPosition: number): string => { return subString.substring(0, spaceIndex); }; +const excludeNoStyles: Array = []; +const excludeReportMentionStyle: Array = ['mentionReport']; + // Enable Markdown parsing. // On web we like to have the Text Input field always focused so the user can easily type a new chat function Composer( @@ -71,6 +75,7 @@ function Composer( isReportActionCompose = false, isComposerFullSize = false, shouldContainScroll = false, + isGroupPolicyReport = false, ...props }: ComposerProps, ref: ForwardedRef, @@ -78,7 +83,7 @@ function Composer( const textContainsOnlyEmojis = useMemo(() => EmojiUtils.containsOnlyEmojis(value ?? ''), [value]); const theme = useTheme(); const styles = useThemeStyles(); - const markdownStyle = useMarkdownStyle(value); + const markdownStyle = useMarkdownStyle(value, !isGroupPolicyReport ? excludeReportMentionStyle : excludeNoStyles); const StyleUtils = useStyleUtils(); const textRef = useRef(null); const textInput = useRef(null); @@ -164,7 +169,6 @@ function Composer( if (textInput.current !== event.target && !(isContenteditableDivFocused && !event.clipboardData?.files.length)) { const eventTarget = event.target as HTMLInputElement | HTMLTextAreaElement | null; - // To make sure the composer does not capture paste events from other inputs, we check where the event originated // If it did originate in another input, we return early to prevent the composer from handling the paste const isTargetInput = eventTarget?.nodeName === 'INPUT' || eventTarget?.nodeName === 'TEXTAREA' || eventTarget?.contentEditable === 'true'; diff --git a/src/components/Composer/types.ts b/src/components/Composer/types.ts index 531bcd03f8bf..0ff91111bd07 100644 --- a/src/components/Composer/types.ts +++ b/src/components/Composer/types.ts @@ -70,6 +70,9 @@ type ComposerProps = TextInputProps & { /** Should make the input only scroll inside the element avoid scroll out to parent */ shouldContainScroll?: boolean; + + /** Indicates whether the composer is in a group policy report. Used for disabling report mentioning style in markdown input */ + isGroupPolicyReport?: boolean; }; export type {TextSelection, ComposerProps}; diff --git a/src/components/ContextMenuItem.tsx b/src/components/ContextMenuItem.tsx index f252cc5b734f..2cc6a0ecec44 100644 --- a/src/components/ContextMenuItem.tsx +++ b/src/components/ContextMenuItem.tsx @@ -52,6 +52,9 @@ type ContextMenuItemProps = { /** Handles what to do when the item is focused */ onFocus?: () => void; + + /** Handles what to do when the item loose focus */ + onBlur?: () => void; }; type ContextMenuItemHandle = { @@ -74,6 +77,7 @@ function ContextMenuItem( shouldPreventDefaultFocusOnPress = true, buttonRef = {current: null}, onFocus = () => {}, + onBlur = () => {}, }: ContextMenuItemProps, ref: ForwardedRef, ) { @@ -130,6 +134,7 @@ function ContextMenuItem( focused={isFocused} interactive={isThrottledButtonActive} onFocus={onFocus} + onBlur={onBlur} /> ); } diff --git a/src/components/EmojiPicker/EmojiPickerMenu/index.tsx b/src/components/EmojiPicker/EmojiPickerMenu/index.tsx index 9d2cd4f60994..5a59585ad72e 100755 --- a/src/components/EmojiPicker/EmojiPickerMenu/index.tsx +++ b/src/components/EmojiPicker/EmojiPickerMenu/index.tsx @@ -7,6 +7,7 @@ import {scrollTo} from 'react-native-reanimated'; import EmojiPickerMenuItem from '@components/EmojiPicker/EmojiPickerMenuItem'; import Text from '@components/Text'; import TextInput from '@components/TextInput'; +import isTextInputFocused from '@components/TextInput/BaseTextInput/isTextInputFocused'; import type {BaseTextInputRef} from '@components/TextInput/BaseTextInput/types'; import useArrowKeyFocusManager from '@hooks/useArrowKeyFocusManager'; import useLocalize from '@hooks/useLocalize'; @@ -86,7 +87,7 @@ function EmojiPickerMenu({onEmojiSelected, activeEmoji}: EmojiPickerMenuProps, r } // If the input is not focused and the new index is out of range, focus the input - if (newIndex < 0 && !searchInputRef.current?.isFocused() && shouldFocusInputOnScreenFocus) { + if (newIndex < 0 && !isTextInputFocused(searchInputRef) && shouldFocusInputOnScreenFocus) { searchInputRef.current?.focus(); } }, @@ -165,12 +166,12 @@ function EmojiPickerMenu({onEmojiSelected, activeEmoji}: EmojiPickerMenuProps, r // Enable keyboard movement if tab or enter is pressed or if shift is pressed while the input // is not focused, so that the navigation and tab cycling can be done using the keyboard without // interfering with the input behaviour. - if (keyBoardEvent.key === 'Tab' || keyBoardEvent.key === 'Enter' || (keyBoardEvent.key === 'Shift' && searchInputRef.current && !searchInputRef.current.isFocused())) { + if (keyBoardEvent.key === 'Tab' || keyBoardEvent.key === 'Enter' || (keyBoardEvent.key === 'Shift' && searchInputRef.current && !isTextInputFocused(searchInputRef))) { setIsUsingKeyboardMovement(true); } // We allow typing in the search box if any key is pressed apart from Arrow keys. - if (searchInputRef.current && !searchInputRef.current.isFocused() && ReportUtils.shouldAutoFocusOnKeyPress(keyBoardEvent)) { + if (searchInputRef.current && !isTextInputFocused(searchInputRef) && ReportUtils.shouldAutoFocusOnKeyPress(keyBoardEvent)) { searchInputRef.current.focus(); } }, diff --git a/src/components/FocusTrap/BOTTOM_TAB_SCREENS.ts b/src/components/FocusTrap/BOTTOM_TAB_SCREENS.ts new file mode 100644 index 000000000000..e6af466b12c5 --- /dev/null +++ b/src/components/FocusTrap/BOTTOM_TAB_SCREENS.ts @@ -0,0 +1,6 @@ +import type {BottomTabName} from '@libs/Navigation/types'; +import SCREENS from '@src/SCREENS'; + +const BOTTOM_TAB_SCREENS: BottomTabName[] = [SCREENS.HOME, SCREENS.SETTINGS.ROOT]; + +export default BOTTOM_TAB_SCREENS; diff --git a/src/components/FocusTrap/FocusTrapForModal/FocusTrapForModalProps.ts b/src/components/FocusTrap/FocusTrapForModal/FocusTrapForModalProps.ts new file mode 100644 index 000000000000..6bc2350a6c55 --- /dev/null +++ b/src/components/FocusTrap/FocusTrapForModal/FocusTrapForModalProps.ts @@ -0,0 +1,6 @@ +type FocusTrapForModalProps = { + children: React.ReactNode; + active: boolean; +}; + +export default FocusTrapForModalProps; diff --git a/src/components/FocusTrap/FocusTrapForModal/index.tsx b/src/components/FocusTrap/FocusTrapForModal/index.tsx new file mode 100644 index 000000000000..01632998b079 --- /dev/null +++ b/src/components/FocusTrap/FocusTrapForModal/index.tsx @@ -0,0 +1,9 @@ +import type FocusTrapForModalProps from './FocusTrapForModalProps'; + +function FocusTrapForModal({children}: FocusTrapForModalProps) { + return children; +} + +FocusTrapForModal.displayName = 'FocusTrapForModal'; + +export default FocusTrapForModal; diff --git a/src/components/FocusTrap/FocusTrapForModal/index.web.tsx b/src/components/FocusTrap/FocusTrapForModal/index.web.tsx new file mode 100644 index 000000000000..161e3f1b7f84 --- /dev/null +++ b/src/components/FocusTrap/FocusTrapForModal/index.web.tsx @@ -0,0 +1,23 @@ +import FocusTrap from 'focus-trap-react'; +import React from 'react'; +import sharedTrapStack from '@components/FocusTrap/sharedTrapStack'; +import type FocusTrapForModalProps from './FocusTrapForModalProps'; + +function FocusTrapForModal({children, active}: FocusTrapForModalProps) { + return ( + + {children} + + ); +} + +FocusTrapForModal.displayName = 'FocusTrapForModal'; + +export default FocusTrapForModal; diff --git a/src/components/FocusTrap/FocusTrapForScreen/FocusTrapProps.ts b/src/components/FocusTrap/FocusTrapForScreen/FocusTrapProps.ts new file mode 100644 index 000000000000..d2f6e5323445 --- /dev/null +++ b/src/components/FocusTrap/FocusTrapForScreen/FocusTrapProps.ts @@ -0,0 +1,5 @@ +type FocusTrapForScreenProps = { + children: React.ReactNode; +}; + +export default FocusTrapForScreenProps; diff --git a/src/components/FocusTrap/FocusTrapForScreen/index.tsx b/src/components/FocusTrap/FocusTrapForScreen/index.tsx new file mode 100644 index 000000000000..ae7ece116d1e --- /dev/null +++ b/src/components/FocusTrap/FocusTrapForScreen/index.tsx @@ -0,0 +1,9 @@ +import type FocusTrapProps from './FocusTrapProps'; + +function FocusTrapForScreen({children}: FocusTrapProps) { + return children; +} + +FocusTrapForScreen.displayName = 'FocusTrapForScreen'; + +export default FocusTrapForScreen; diff --git a/src/components/FocusTrap/FocusTrapForScreen/index.web.tsx b/src/components/FocusTrap/FocusTrapForScreen/index.web.tsx new file mode 100644 index 000000000000..6a1409ab4a93 --- /dev/null +++ b/src/components/FocusTrap/FocusTrapForScreen/index.web.tsx @@ -0,0 +1,72 @@ +import {useFocusEffect, useIsFocused, useRoute} from '@react-navigation/native'; +import FocusTrap from 'focus-trap-react'; +import React, {useCallback, useMemo} from 'react'; +import BOTTOM_TAB_SCREENS from '@components/FocusTrap/BOTTOM_TAB_SCREENS'; +import SCREENS_WITH_AUTOFOCUS from '@components/FocusTrap/SCREENS_WITH_AUTOFOCUS'; +import sharedTrapStack from '@components/FocusTrap/sharedTrapStack'; +import TOP_TAB_SCREENS from '@components/FocusTrap/TOP_TAB_SCREENS'; +import WIDE_LAYOUT_INACTIVE_SCREENS from '@components/FocusTrap/WIDE_LAYOUT_INACTIVE_SCREENS'; +import useWindowDimensions from '@hooks/useWindowDimensions'; +import type FocusTrapProps from './FocusTrapProps'; + +let activeRouteName = ''; +function FocusTrapForScreen({children}: FocusTrapProps) { + const isFocused = useIsFocused(); + const route = useRoute(); + const {isSmallScreenWidth} = useWindowDimensions(); + + const isActive = useMemo(() => { + // Focus trap can't be active on bottom tab screens because it would block access to the tab bar. + if (BOTTOM_TAB_SCREENS.find((screen) => screen === route.name)) { + return false; + } + + // in top tabs only focus trap for currently shown tab should be active + if (TOP_TAB_SCREENS.find((screen) => screen === route.name)) { + return isFocused; + } + + // Focus trap can't be active on these screens if the layout is wide because they may be displayed side by side. + if (WIDE_LAYOUT_INACTIVE_SCREENS.includes(route.name) && !isSmallScreenWidth) { + return false; + } + return true; + }, [isFocused, isSmallScreenWidth, route.name]); + + useFocusEffect( + useCallback(() => { + activeRouteName = route.name; + }, [route]), + ); + + return ( + { + if (SCREENS_WITH_AUTOFOCUS.includes(activeRouteName)) { + return false; + } + return undefined; + }, + setReturnFocus: (element) => { + if (SCREENS_WITH_AUTOFOCUS.includes(activeRouteName)) { + return false; + } + return element; + }, + }} + > + {children} + + ); +} + +FocusTrapForScreen.displayName = 'FocusTrapForScreen'; + +export default FocusTrapForScreen; diff --git a/src/components/FocusTrap/SCREENS_WITH_AUTOFOCUS.ts b/src/components/FocusTrap/SCREENS_WITH_AUTOFOCUS.ts new file mode 100644 index 000000000000..2a77b52e3116 --- /dev/null +++ b/src/components/FocusTrap/SCREENS_WITH_AUTOFOCUS.ts @@ -0,0 +1,15 @@ +import {CENTRAL_PANE_WORKSPACE_SCREENS} from '@libs/Navigation/AppNavigator/Navigators/FullScreenNavigator'; +import SCREENS from '@src/SCREENS'; + +const SCREENS_WITH_AUTOFOCUS: string[] = [ + ...Object.keys(CENTRAL_PANE_WORKSPACE_SCREENS), + SCREENS.REPORT, + SCREENS.REPORT_DESCRIPTION_ROOT, + SCREENS.PRIVATE_NOTES.EDIT, + SCREENS.SETTINGS.PROFILE.STATUS, + SCREENS.SETTINGS.PROFILE.PRONOUNS, + SCREENS.NEW_TASK.DETAILS, + SCREENS.MONEY_REQUEST.CREATE, +]; + +export default SCREENS_WITH_AUTOFOCUS; diff --git a/src/components/FocusTrap/TOP_TAB_SCREENS.ts b/src/components/FocusTrap/TOP_TAB_SCREENS.ts new file mode 100644 index 000000000000..6bee36b86883 --- /dev/null +++ b/src/components/FocusTrap/TOP_TAB_SCREENS.ts @@ -0,0 +1,5 @@ +import CONST from '@src/CONST'; + +const TOP_TAB_SCREENS: string[] = [CONST.TAB.NEW_CHAT, CONST.TAB.NEW_ROOM, CONST.TAB_REQUEST.DISTANCE, CONST.TAB_REQUEST.MANUAL, CONST.TAB_REQUEST.SCAN]; + +export default TOP_TAB_SCREENS; diff --git a/src/components/FocusTrap/WIDE_LAYOUT_INACTIVE_SCREENS.ts b/src/components/FocusTrap/WIDE_LAYOUT_INACTIVE_SCREENS.ts new file mode 100644 index 000000000000..2d0c51edbba9 --- /dev/null +++ b/src/components/FocusTrap/WIDE_LAYOUT_INACTIVE_SCREENS.ts @@ -0,0 +1,36 @@ +import NAVIGATORS from '@src/NAVIGATORS'; +import SCREENS from '@src/SCREENS'; + +// Screens that should not have active focus trap when rendered on wide screen in order to allow Tab navigation in LHP and RHP +const WIDE_LAYOUT_INACTIVE_SCREENS: string[] = [ + NAVIGATORS.BOTTOM_TAB_NAVIGATOR, + SCREENS.HOME, + SCREENS.SETTINGS.ROOT, + SCREENS.REPORT, + SCREENS.SETTINGS.PROFILE.ROOT, + SCREENS.SETTINGS.PREFERENCES.ROOT, + SCREENS.SETTINGS.SECURITY, + SCREENS.SETTINGS.WALLET.ROOT, + SCREENS.SETTINGS.ABOUT, + SCREENS.SETTINGS.WORKSPACES, + SCREENS.WORKSPACE.INITIAL, + SCREENS.WORKSPACE.PROFILE, + SCREENS.WORKSPACE.CARD, + SCREENS.WORKSPACE.WORKFLOWS, + SCREENS.WORKSPACE.WORKFLOWS_APPROVER, + SCREENS.WORKSPACE.WORKFLOWS_AUTO_REPORTING_FREQUENCY, + SCREENS.WORKSPACE.WORKFLOWS_AUTO_REPORTING_MONTHLY_OFFSET, + SCREENS.WORKSPACE.REIMBURSE, + SCREENS.WORKSPACE.BILLS, + SCREENS.WORKSPACE.INVOICES, + SCREENS.WORKSPACE.TRAVEL, + SCREENS.WORKSPACE.MEMBERS, + SCREENS.WORKSPACE.CATEGORIES, + SCREENS.WORKSPACE.MORE_FEATURES, + SCREENS.WORKSPACE.TAGS, + SCREENS.WORKSPACE.TAXES, + SCREENS.WORKSPACE.DISTANCE_RATES, + SCREENS.SEARCH.CENTRAL_PANE, +]; + +export default WIDE_LAYOUT_INACTIVE_SCREENS; diff --git a/src/components/FocusTrap/sharedTrapStack.ts b/src/components/FocusTrap/sharedTrapStack.ts new file mode 100644 index 000000000000..d0fa12f3fe93 --- /dev/null +++ b/src/components/FocusTrap/sharedTrapStack.ts @@ -0,0 +1,6 @@ +import type {FocusTrap as FocusTrapHandler} from 'focus-trap'; + +// focus-trap is capable of managing many traps. It's necessary for RHP and modals +const trapStack: FocusTrapHandler[] = []; + +export default trapStack; diff --git a/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.tsx b/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.tsx index 51f9981f1524..d95fc9e11a31 100755 --- a/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.tsx +++ b/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.tsx @@ -77,8 +77,13 @@ function BaseHTMLEngineProvider({textSelectable = false, children, enableExperim mixedUAStyles: {...styles.textSupporting, ...styles.textLineThrough}, contentModel: HTMLContentModel.textual, }), + 'uploading-attachment': HTMLElementModel.fromCustomModel({ + tagName: 'uploading-attachment', + mixedUAStyles: {...styles.mt4}, + contentModel: HTMLContentModel.block, + }), }), - [styles.formError, styles.mb0, styles.colorMuted, styles.textLabelSupporting, styles.lh16, styles.textSupporting, styles.textLineThrough], + [styles.formError, styles.mb0, styles.colorMuted, styles.textLabelSupporting, styles.lh16, styles.textSupporting, styles.textLineThrough, styles.mt4], ); /* eslint-enable @typescript-eslint/naming-convention */ diff --git a/src/components/HeaderWithBackButton/index.tsx b/src/components/HeaderWithBackButton/index.tsx index 78cd92dbc223..2d73e3c2dd24 100755 --- a/src/components/HeaderWithBackButton/index.tsx +++ b/src/components/HeaderWithBackButton/index.tsx @@ -30,7 +30,7 @@ function HeaderWithBackButton({ onCloseButtonPress = () => Navigation.dismissModal(), onDownloadButtonPress = () => {}, onThreeDotsButtonPress = () => {}, - report = null, + report, policy, policyAvatar, shouldShowReportAvatarWithDisplay = false, @@ -158,7 +158,7 @@ function HeaderWithBackButton({ } }} style={[styles.touchableButtonImage]} - role="button" + role={CONST.ROLE.BUTTON} accessibilityLabel={translate('common.back')} id={CONST.BACK_BUTTON_NATIVE_ID} > diff --git a/src/components/Hoverable/ActiveHoverable.tsx b/src/components/Hoverable/ActiveHoverable.tsx index dd3f41c52578..b58433afb17c 100644 --- a/src/components/Hoverable/ActiveHoverable.tsx +++ b/src/components/Hoverable/ActiveHoverable.tsx @@ -8,6 +8,8 @@ import type HoverableProps from './types'; type ActiveHoverableProps = Omit; +type OnMouseEvent = (e: MouseEvent) => void; + function ActiveHoverable({onHoverIn, onHoverOut, shouldHandleScroll, shouldFreezeCapture, children}: ActiveHoverableProps, outerRef: Ref) { const [isHovered, setIsHovered] = useState(false); @@ -98,9 +100,10 @@ function ActiveHoverable({onHoverIn, onHoverOut, shouldHandleScroll, shouldFreez const child = useMemo(() => getReturnValue(children, !isScrollingRef.current && isHovered), [children, isHovered]); - const childOnMouseEnter = child.props.onMouseEnter; - const childOnMouseLeave = child.props.onMouseLeave; - const childOnMouseMove = child.props.onMouseMove; + const childOnMouseEnter: OnMouseEvent = child.props.onMouseEnter; + const childOnMouseLeave: OnMouseEvent = child.props.onMouseLeave; + const childOnMouseMove: OnMouseEvent = child.props.onMouseMove; + const childOnBlur: OnMouseEvent = child.props.onBlur; const hoverAndForwardOnMouseEnter = useCallback( (e: MouseEvent) => { @@ -127,9 +130,9 @@ function ActiveHoverable({onHoverIn, onHoverOut, shouldHandleScroll, shouldFreez setIsHovered(false); } - child.props.onBlur?.(event); + childOnBlur?.(event); }, - [child.props], + [childOnBlur], ); const handleAndForwardOnMouseMove = useCallback( diff --git a/src/components/Icon/Illustrations.ts b/src/components/Icon/Illustrations.ts index 53b8aa8acb72..3fe36239d631 100644 --- a/src/components/Icon/Illustrations.ts +++ b/src/components/Icon/Illustrations.ts @@ -49,6 +49,7 @@ import CompanyCard from '@assets/images/simple-illustrations/simple-illustration import ConciergeBubble from '@assets/images/simple-illustrations/simple-illustration__concierge-bubble.svg'; import ConciergeNew from '@assets/images/simple-illustrations/simple-illustration__concierge.svg'; import CreditCardsNew from '@assets/images/simple-illustrations/simple-illustration__credit-cards.svg'; +import CreditCardEyes from '@assets/images/simple-illustrations/simple-illustration__creditcardeyes.svg'; import EmailAddress from '@assets/images/simple-illustrations/simple-illustration__email-address.svg'; import FolderOpen from '@assets/images/simple-illustrations/simple-illustration__folder-open.svg'; import Gears from '@assets/images/simple-illustrations/simple-illustration__gears.svg'; @@ -190,4 +191,5 @@ export { ExpensifyApprovedLogoLight, SendMoney, CheckmarkCircle, + CreditCardEyes, }; diff --git a/src/components/KYCWall/BaseKYCWall.tsx b/src/components/KYCWall/BaseKYCWall.tsx index 8c4a131284c8..818b4aff6b00 100644 --- a/src/components/KYCWall/BaseKYCWall.tsx +++ b/src/components/KYCWall/BaseKYCWall.tsx @@ -166,7 +166,7 @@ function KYCWall({ transferBalanceButtonRef.current = targetElement; - const isExpenseReport = ReportUtils.isExpenseReport(iouReport ?? null); + const isExpenseReport = ReportUtils.isExpenseReport(iouReport); const paymentCardList = fundList ?? {}; // Check to see if user has a valid payment method on file and display the add payment popover if they don't diff --git a/src/components/KeyboardAvoidingView/index.ios.tsx b/src/components/KeyboardAvoidingView/index.ios.tsx index a7cd767377ef..171210eab7ac 100644 --- a/src/components/KeyboardAvoidingView/index.ios.tsx +++ b/src/components/KeyboardAvoidingView/index.ios.tsx @@ -3,7 +3,7 @@ */ import React from 'react'; import {KeyboardAvoidingView as KeyboardAvoidingViewComponent} from 'react-native'; -import type KeyboardAvoidingViewProps from './types'; +import type {KeyboardAvoidingViewProps} from './types'; function KeyboardAvoidingView(props: KeyboardAvoidingViewProps) { // eslint-disable-next-line react/jsx-props-no-spreading diff --git a/src/components/KeyboardAvoidingView/index.tsx b/src/components/KeyboardAvoidingView/index.tsx index 09ec21e5b219..c0882ae1e9cc 100644 --- a/src/components/KeyboardAvoidingView/index.tsx +++ b/src/components/KeyboardAvoidingView/index.tsx @@ -3,7 +3,7 @@ */ import React from 'react'; import {View} from 'react-native'; -import type KeyboardAvoidingViewProps from './types'; +import type {KeyboardAvoidingViewProps} from './types'; function KeyboardAvoidingView(props: KeyboardAvoidingViewProps) { const {behavior, contentContainerStyle, enabled, keyboardVerticalOffset, ...rest} = props; diff --git a/src/components/KeyboardAvoidingView/types.ts b/src/components/KeyboardAvoidingView/types.ts index 48d354e8b53f..2c1ef64ced8f 100644 --- a/src/components/KeyboardAvoidingView/types.ts +++ b/src/components/KeyboardAvoidingView/types.ts @@ -1,3 +1,4 @@ -import {KeyboardAvoidingViewProps} from 'react-native'; +import type {KeyboardAvoidingViewProps} from 'react-native'; -export default KeyboardAvoidingViewProps; +// eslint-disable-next-line import/prefer-default-export +export type {KeyboardAvoidingViewProps}; diff --git a/src/components/LHNOptionsList/LHNOptionsList.tsx b/src/components/LHNOptionsList/LHNOptionsList.tsx index 6405e3026b1a..02f301f52845 100644 --- a/src/components/LHNOptionsList/LHNOptionsList.tsx +++ b/src/components/LHNOptionsList/LHNOptionsList.tsx @@ -4,7 +4,7 @@ import {FlashList} from '@shopify/flash-list'; import type {ReactElement} from 'react'; import React, {memo, useCallback, useContext, useEffect, useMemo, useRef} from 'react'; import {StyleSheet, View} from 'react-native'; -import {withOnyx} from 'react-native-onyx'; +import {useOnyx} from 'react-native-onyx'; import BlockingView from '@components/BlockingViews/BlockingView'; import Icon from '@components/Icon'; import * as Expensicons from '@components/Icon/Expensicons'; @@ -23,35 +23,27 @@ import variables from '@styles/variables'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import OptionRowLHNData from './OptionRowLHNData'; -import type {LHNOptionsListOnyxProps, LHNOptionsListProps, RenderItemProps} from './types'; +import type {LHNOptionsListProps, RenderItemProps} from './types'; const keyExtractor = (item: string) => `report_${item}`; -function LHNOptionsList({ - style, - contentContainerStyles, - data, - onSelectRow, - optionMode, - shouldDisableFocusOptions = false, - reports = {}, - reportActions = {}, - policy = {}, - preferredLocale = CONST.LOCALES.DEFAULT, - personalDetails = {}, - transactions = {}, - draftComments = {}, - transactionViolations = {}, - onFirstItemRendered = () => {}, -}: LHNOptionsListProps) { +function LHNOptionsList({style, contentContainerStyles, data, onSelectRow, optionMode, shouldDisableFocusOptions = false, onFirstItemRendered = () => {}}: LHNOptionsListProps) { const {saveScrollOffset, getScrollOffset} = useContext(ScrollOffsetContext); const flashListRef = useRef>(null); const route = useRoute(); + const [reports] = useOnyx(ONYXKEYS.COLLECTION.REPORT); + const [reportActions] = useOnyx(ONYXKEYS.COLLECTION.REPORT_ACTIONS); + const [policy] = useOnyx(ONYXKEYS.COLLECTION.POLICY); + const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST); + const [transactions] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION); + const [draftComments] = useOnyx(ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT); + const [transactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS); + const theme = useTheme(); const styles = useThemeStyles(); const {canUseViolations} = usePermissions(); - const {translate} = useLocalize(); + const {translate, preferredLocale} = useLocalize(); const {shouldUseNarrowLayout} = useResponsiveLayout(); const shouldShowEmptyLHN = shouldUseNarrowLayout && data.length === 0; @@ -106,13 +98,13 @@ function LHNOptionsList({ */ const renderItem = useCallback( ({item: reportID}: RenderItemProps): ReactElement => { - const itemFullReport = reports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`] ?? null; - const itemReportActions = reportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`] ?? null; - const itemParentReportActions = reportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${itemFullReport?.parentReportID}`] ?? null; - const itemParentReportAction = itemParentReportActions?.[itemFullReport?.parentReportActionID ?? ''] ?? null; - const itemPolicy = policy?.[`${ONYXKEYS.COLLECTION.POLICY}${itemFullReport?.policyID}`] ?? null; + const itemFullReport = reports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; + const itemReportActions = reportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`]; + const itemParentReportActions = reportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${itemFullReport?.parentReportID}`]; + const itemParentReportAction = itemParentReportActions?.[itemFullReport?.parentReportActionID ?? '']; + const itemPolicy = policy?.[`${ONYXKEYS.COLLECTION.POLICY}${itemFullReport?.policyID}`]; const transactionID = itemParentReportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.IOU ? itemParentReportAction.originalMessage.IOUTransactionID ?? '' : ''; - const itemTransaction = transactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`] ?? null; + const itemTransaction = transactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]; const hasDraftComment = DraftCommentUtils.isValidDraftComment(draftComments?.[`${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${reportID}`]); const sortedReportActions = ReportActionsUtils.getSortedReportActionsForDisplay(itemReportActions); const lastReportAction = sortedReportActions[0]; @@ -165,8 +157,8 @@ function LHNOptionsList({ ); const extraData = useMemo( - () => [reportActions, reports, policy, personalDetails, data.length, draftComments], - [reportActions, reports, policy, personalDetails, data.length, draftComments], + () => [reportActions, reports, policy, personalDetails, data.length, draftComments, optionMode], + [reportActions, reports, policy, personalDetails, data.length, draftComments, optionMode], ); const previousOptionMode = usePrevious(optionMode); @@ -246,31 +238,6 @@ function LHNOptionsList({ LHNOptionsList.displayName = 'LHNOptionsList'; -export default withOnyx({ - reports: { - key: ONYXKEYS.COLLECTION.REPORT, - }, - reportActions: { - key: ONYXKEYS.COLLECTION.REPORT_ACTIONS, - }, - policy: { - key: ONYXKEYS.COLLECTION.POLICY, - }, - preferredLocale: { - key: ONYXKEYS.NVP_PREFERRED_LOCALE, - }, - personalDetails: { - key: ONYXKEYS.PERSONAL_DETAILS_LIST, - }, - transactions: { - key: ONYXKEYS.COLLECTION.TRANSACTION, - }, - draftComments: { - key: ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT, - }, - transactionViolations: { - key: ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS, - }, -})(memo(LHNOptionsList)); +export default memo(LHNOptionsList); export type {LHNOptionsListProps}; diff --git a/src/components/LHNOptionsList/OptionRowLHN.tsx b/src/components/LHNOptionsList/OptionRowLHN.tsx index 851e2a9fdd16..de7ffabe035e 100644 --- a/src/components/LHNOptionsList/OptionRowLHN.tsx +++ b/src/components/LHNOptionsList/OptionRowLHN.tsx @@ -123,7 +123,7 @@ function OptionRowLHN({reportID, isFocused = false, onSelectRow = () => {}, opti const formattedDate = DateUtils.getStatusUntilDate(statusClearAfterDate); const statusContent = formattedDate ? `${statusText ? `${statusText} ` : ''}(${formattedDate})` : statusText; const report = ReportUtils.getReport(optionItem.reportID ?? ''); - const isStatusVisible = !!emojiCode && ReportUtils.isOneOnOneChat(!isEmptyObject(report) ? report : null); + const isStatusVisible = !!emojiCode && ReportUtils.isOneOnOneChat(!isEmptyObject(report) ? report : undefined); const isGroupChat = ReportUtils.isGroupChat(optionItem) || ReportUtils.isDeprecatedGroupDM(optionItem); @@ -221,7 +221,8 @@ function OptionRowLHN({reportID, isFocused = false, onSelectRow = () => {}, opti !!optionItem.isThread || !!optionItem.isMoneyRequestReport || !!optionItem.isInvoiceReport || - ReportUtils.isGroupChat(report) + ReportUtils.isGroupChat(report) || + ReportUtils.isSystemChat(report) } /> {isStatusVisible && ( diff --git a/src/components/LHNOptionsList/OptionRowLHNData.tsx b/src/components/LHNOptionsList/OptionRowLHNData.tsx index c80017c39a3d..a2dd41eab0bd 100644 --- a/src/components/LHNOptionsList/OptionRowLHNData.tsx +++ b/src/components/LHNOptionsList/OptionRowLHNData.tsx @@ -35,7 +35,7 @@ function OptionRowLHNData({ const optionItemRef = useRef(); - const shouldDisplayViolations = canUseViolations && ReportUtils.shouldDisplayTransactionThreadViolations(fullReport, transactionViolations, parentReportAction ?? null); + const shouldDisplayViolations = canUseViolations && ReportUtils.shouldDisplayTransactionThreadViolations(fullReport, transactionViolations, parentReportAction); const optionItem = useMemo(() => { // Note: ideally we'd have this as a dependent selector in onyx! diff --git a/src/components/LHNOptionsList/types.ts b/src/components/LHNOptionsList/types.ts index 0f0c921747b4..7248742654d2 100644 --- a/src/components/LHNOptionsList/types.ts +++ b/src/components/LHNOptionsList/types.ts @@ -10,32 +10,6 @@ import type {EmptyObject} from '@src/types/utils/EmptyObject'; type OptionMode = ValueOf; -type LHNOptionsListOnyxProps = { - /** The policy which the user has access to and which the report could be tied to */ - policy: OnyxCollection; - - /** All reports shared with the user */ - reports: OnyxCollection; - - /** Array of report actions for this report */ - reportActions: OnyxCollection; - - /** Indicates which locale the user currently has selected */ - preferredLocale: OnyxEntry; - - /** List of users' personal details */ - personalDetails: OnyxEntry; - - /** The transaction from the parent report action */ - transactions: OnyxCollection; - - /** List of draft comments */ - draftComments: OnyxCollection; - - /** The list of transaction violations */ - transactionViolations: OnyxCollection; -}; - type CustomLHNOptionsListProps = { /** Wrapper style for the section list */ style?: StyleProp; @@ -59,7 +33,7 @@ type CustomLHNOptionsListProps = { onFirstItemRendered: () => void; }; -type LHNOptionsListProps = CustomLHNOptionsListProps & LHNOptionsListOnyxProps; +type LHNOptionsListProps = CustomLHNOptionsListProps; type OptionRowLHNDataProps = { /** Whether row should be focused */ @@ -141,4 +115,4 @@ type OptionRowLHNProps = { type RenderItemProps = {item: string}; -export type {LHNOptionsListProps, OptionRowLHNDataProps, OptionRowLHNProps, LHNOptionsListOnyxProps, RenderItemProps}; +export type {LHNOptionsListProps, OptionRowLHNDataProps, OptionRowLHNProps, RenderItemProps}; diff --git a/src/components/LottieAnimations/index.tsx b/src/components/LottieAnimations/index.tsx index 598819e19361..00089f8094f1 100644 --- a/src/components/LottieAnimations/index.tsx +++ b/src/components/LottieAnimations/index.tsx @@ -3,6 +3,11 @@ import variables from '@styles/variables'; import type DotLottieAnimation from './types'; const DotLottieAnimations = { + Abracadabra: { + file: require('@assets/animations/Abracadabra.lottie'), + w: 375, + h: 400, + }, FastMoney: { file: require('@assets/animations/FastMoney.lottie'), w: 375, @@ -46,6 +51,11 @@ const DotLottieAnimations = { h: 400, backgroundColor: colors.ice500, }, + MagicCode: { + file: require('@assets/animations/MagicCode.lottie'), + w: 200, + h: 164, + }, Magician: { file: require('@assets/animations/Magician.lottie'), w: 853, diff --git a/src/components/MenuItem.tsx b/src/components/MenuItem.tsx index 554ae9d4a84a..6a0ca0c9f5e3 100644 --- a/src/components/MenuItem.tsx +++ b/src/components/MenuItem.tsx @@ -207,7 +207,7 @@ type MenuItemBaseProps = { shouldStackHorizontally?: boolean; /** Prop to represent the size of the avatar images to be shown */ - avatarSize?: (typeof CONST.AVATAR_SIZE)[keyof typeof CONST.AVATAR_SIZE]; + avatarSize?: ValueOf; /** Avatars to show on the right of the menu item */ floatRightAvatars?: IconType[]; @@ -281,6 +281,9 @@ type MenuItemBaseProps = { /** Handles what to do when the item is focused */ onFocus?: () => void; + /** Handles what to do when the item loose focus */ + onBlur?: () => void; + /** Optional account id if it's user avatar or policy id if it's workspace avatar */ avatarID?: number | string; }; @@ -365,6 +368,7 @@ function MenuItem( isPaneMenu = false, shouldPutLeftPaddingWhenNoIcon = false, onFocus, + onBlur, avatarID, }: MenuItemProps, ref: PressableRef, @@ -462,7 +466,7 @@ function MenuItem( }; return ( - + {!!label && !isLabelHoverable && ( {label} diff --git a/src/components/Modal/BaseModal.tsx b/src/components/Modal/BaseModal.tsx index e24c5b7c9c80..f76fc94dbf89 100644 --- a/src/components/Modal/BaseModal.tsx +++ b/src/components/Modal/BaseModal.tsx @@ -2,6 +2,7 @@ import React, {forwardRef, useCallback, useEffect, useMemo, useRef} from 'react' import {View} from 'react-native'; import ReactNativeModal from 'react-native-modal'; import ColorSchemeWrapper from '@components/ColorSchemeWrapper'; +import FocusTrapForModal from '@components/FocusTrap/FocusTrapForModal'; import useKeyboardState from '@hooks/useKeyboardState'; import usePrevious from '@hooks/usePrevious'; import useSafeAreaInsets from '@hooks/useSafeAreaInsets'; @@ -214,7 +215,7 @@ function BaseModal( // a conflict between RN core and Reanimated shadow tree operations // position absolute is needed to prevent the view from interfering with flex layout collapsable={false} - style={[styles.pAbsolute]} + style={[styles.pAbsolute, {zIndex: 1}]} > : undefined} > - - - {children} + + + + + {children} + + - + diff --git a/src/components/Modal/index.tsx b/src/components/Modal/index.tsx index 76f4b251ec83..325fb4ca6ab8 100644 --- a/src/components/Modal/index.tsx +++ b/src/components/Modal/index.tsx @@ -22,7 +22,7 @@ function Modal({fullscreen = true, onModalHide = () => {}, type, onModalShow = ( const hideModal = () => { setStatusBarColor(previousStatusBarColor); onModalHide(); - if (window.history.state.shouldGoBack) { + if (window.history.state?.shouldGoBack) { window.history.back(); } }; diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index ec52a6158ad7..d3cf50827cec 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -1,4 +1,4 @@ -import React, {useCallback, useEffect, useMemo, useState} from 'react'; +import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import {useOnyx} from 'react-native-onyx'; @@ -8,6 +8,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; import * as CurrencyUtils from '@libs/CurrencyUtils'; import * as HeaderUtils from '@libs/HeaderUtils'; +import Navigation from '@libs/Navigation/Navigation'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; import * as ReportUtils from '@libs/ReportUtils'; import * as TransactionUtils from '@libs/TransactionUtils'; @@ -17,6 +18,7 @@ import * as TransactionActions from '@userActions/Transaction'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; +import type {Route} from '@src/ROUTES'; import type * as OnyxTypes from '@src/types/onyx'; import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; @@ -27,6 +29,7 @@ import Icon from './Icon'; import * as Expensicons from './Icon/Expensicons'; import MoneyReportHeaderStatusBar from './MoneyReportHeaderStatusBar'; import MoneyRequestHeaderStatusBar from './MoneyRequestHeaderStatusBar'; +import type {ActionHandledType} from './ProcessMoneyReportHoldMenu'; import ProcessMoneyReportHoldMenu from './ProcessMoneyReportHoldMenu'; import SettlementButton from './SettlementButton'; @@ -79,13 +82,14 @@ function MoneyReportHeader({policy, report: moneyRequestReport, transactionThrea isActionOwner && (ReportUtils.canAddOrDeleteTransactions(moneyRequestReport) || ReportUtils.isTrackExpenseReport(transactionThreadReport)) && !isDeletedParentAction; const [isHoldMenuVisible, setIsHoldMenuVisible] = useState(false); const [paymentType, setPaymentType] = useState(); - const [requestType, setRequestType] = useState<'pay' | 'approve'>(); + const [requestType, setRequestType] = useState(); const canAllowSettlement = ReportUtils.hasUpdatedTotal(moneyRequestReport, policy); const policyType = policy?.type; const isPayer = ReportUtils.isPayer(session, moneyRequestReport); const isDraft = ReportUtils.isOpenExpenseReport(moneyRequestReport); const [isConfirmModalVisible, setIsConfirmModalVisible] = useState(false); + const navigateBackToAfterDelete = useRef(); const hasScanningReceipt = ReportUtils.getTransactionsWithReceipts(moneyRequestReport?.reportID).some((transaction) => TransactionUtils.isReceiptBeingScanned(transaction)); const transactionIDs = TransactionUtils.getAllReportTransactions(moneyRequestReport?.reportID).map((transaction) => transaction.transactionID); const allHavePendingRTERViolation = TransactionUtils.allHavePendingRTERViolation(transactionIDs); @@ -124,7 +128,7 @@ function MoneyReportHeader({policy, report: moneyRequestReport, transactionThrea return; } setPaymentType(type); - setRequestType('pay'); + setRequestType(CONST.IOU.REPORT_ACTION_TYPE.PAY); if (ReportUtils.hasHeldExpenses(moneyRequestReport.reportID)) { setIsHoldMenuVisible(true); } else if (ReportUtils.isInvoiceReport(moneyRequestReport)) { @@ -135,7 +139,7 @@ function MoneyReportHeader({policy, report: moneyRequestReport, transactionThrea }; const confirmApproval = () => { - setRequestType('approve'); + setRequestType(CONST.IOU.REPORT_ACTION_TYPE.APPROVE); if (ReportUtils.hasHeldExpenses(moneyRequestReport.reportID)) { setIsHoldMenuVisible(true); } else { @@ -147,10 +151,10 @@ function MoneyReportHeader({policy, report: moneyRequestReport, transactionThrea if (requestParentReportAction) { const iouTransactionID = requestParentReportAction.actionName === CONST.REPORT.ACTIONS.TYPE.IOU ? requestParentReportAction.originalMessage?.IOUTransactionID ?? '' : ''; if (ReportActionsUtils.isTrackExpenseAction(requestParentReportAction)) { - IOU.deleteTrackExpense(moneyRequestReport?.reportID ?? '', iouTransactionID, requestParentReportAction, true); - return; + navigateBackToAfterDelete.current = IOU.deleteTrackExpense(moneyRequestReport?.reportID ?? '', iouTransactionID, requestParentReportAction, true); + } else { + navigateBackToAfterDelete.current = IOU.deleteMoneyRequest(iouTransactionID, requestParentReportAction, true); } - IOU.deleteMoneyRequest(iouTransactionID, requestParentReportAction, true); } setIsDeleteRequestModalVisible(false); @@ -366,6 +370,12 @@ function MoneyReportHeader({policy, report: moneyRequestReport, transactionThrea isVisible={isDeleteRequestModalVisible} onConfirm={deleteTransaction} onCancel={() => setIsDeleteRequestModalVisible(false)} + onModalHide={() => { + if (!navigateBackToAfterDelete.current) { + return; + } + Navigation.goBack(navigateBackToAfterDelete.current); + }} prompt={translate('iou.deleteConfirmation')} confirmText={translate('common.delete')} cancelText={translate('common.cancel')} diff --git a/src/components/MoneyRequestAmountInput.tsx b/src/components/MoneyRequestAmountInput.tsx index 1cccfdc720b3..0c3868312c41 100644 --- a/src/components/MoneyRequestAmountInput.tsx +++ b/src/components/MoneyRequestAmountInput.tsx @@ -7,7 +7,9 @@ import * as Browser from '@libs/Browser'; import * as CurrencyUtils from '@libs/CurrencyUtils'; import getOperatingSystem from '@libs/getOperatingSystem'; import * as MoneyRequestUtils from '@libs/MoneyRequestUtils'; +import shouldIgnoreSelectionWhenUpdatedManually from '@libs/shouldIgnoreSelectionWhenUpdatedManually'; import CONST from '@src/CONST'; +import isTextInputFocused from './TextInput/BaseTextInput/isTextInputFocused'; import type {BaseTextInputRef} from './TextInput/BaseTextInput/types'; import TextInputWithCurrencySymbol from './TextInputWithCurrencySymbol'; @@ -140,6 +142,8 @@ function MoneyRequestAmountInput( }); const forwardDeletePressedRef = useRef(false); + // The ref is used to ignore any onSelectionChange event that happens while we are updating the selection manually in setNewAmount + const willSelectionBeUpdatedManually = useRef(false); /** * Sets the selection and the amount accordingly to the value passed to the input @@ -162,6 +166,7 @@ function MoneyRequestAmountInput( // setCurrentAmount contains another setState(setSelection) making it error-prone since it is leading to setSelection being called twice for a single setCurrentAmount call. This solution introducing the hasSelectionBeenSet flag was chosen for its simplicity and lower risk of future errors https://github.com/Expensify/App/issues/23300#issuecomment-1766314724. + willSelectionBeUpdatedManually.current = true; let hasSelectionBeenSet = false; setCurrentAmount((prevAmount) => { const strippedAmount = MoneyRequestUtils.stripCommaFromAmount(finalAmount); @@ -169,6 +174,7 @@ function MoneyRequestAmountInput( if (!hasSelectionBeenSet) { hasSelectionBeenSet = true; setSelection((prevSelection) => getNewSelection(prevSelection, isForwardDelete ? strippedAmount.length : prevAmount.length, strippedAmount.length)); + willSelectionBeUpdatedManually.current = false; } onAmountChange?.(strippedAmount); return strippedAmount; @@ -196,7 +202,7 @@ function MoneyRequestAmountInput( })); useEffect(() => { - if (!currency || typeof amount !== 'number' || (formatAmountOnBlur && textInput.current?.isFocused()) || shouldKeepUserInput) { + if ((!currency || typeof amount !== 'number' || (formatAmountOnBlur && isTextInputFocused(textInput))) ?? shouldKeepUserInput) { return; } const frontendAmount = onFormatAmount(amount, currency); @@ -294,6 +300,10 @@ function MoneyRequestAmountInput( selectedCurrencyCode={currency} selection={selection} onSelectionChange={(e: NativeSyntheticEvent) => { + if (shouldIgnoreSelectionWhenUpdatedManually && willSelectionBeUpdatedManually.current) { + willSelectionBeUpdatedManually.current = false; + return; + } if (!shouldUpdateSelection) { return; } diff --git a/src/components/MoneyRequestConfirmationList.tsx b/src/components/MoneyRequestConfirmationList.tsx index e7c799fea3b6..cdb3aa8da1e1 100755 --- a/src/components/MoneyRequestConfirmationList.tsx +++ b/src/components/MoneyRequestConfirmationList.tsx @@ -133,7 +133,7 @@ type MoneyRequestConfirmationListProps = MoneyRequestConfirmationListOnyxProps & selectedParticipants: Participant[]; /** Payee of the expense with login */ - payeePersonalDetails?: OnyxEntry; + payeePersonalDetails?: OnyxEntry | null; /** Should the list be read only, and not editable? */ isReadOnly?: boolean; @@ -184,7 +184,7 @@ type MoneyRequestConfirmationListProps = MoneyRequestConfirmationListOnyxProps & type MoneyRequestConfirmationListItem = Participant | ReportUtils.OptionData; function MoneyRequestConfirmationList({ - transaction = null, + transaction, onSendMoney, onConfirm, iouType = CONST.IOU.TYPE.SUBMIT, @@ -709,7 +709,7 @@ function MoneyRequestConfirmationList({ if (selectedParticipants.length === 0) { return; } - if (!isEditingSplitBill && isMerchantRequired && (isMerchantEmpty || (shouldDisplayFieldError && TransactionUtils.isMerchantMissing(transaction ?? null)))) { + if (!isEditingSplitBill && isMerchantRequired && (isMerchantEmpty || (shouldDisplayFieldError && TransactionUtils.isMerchantMissing(transaction)))) { setFormError('iou.error.invalidMerchant'); return; } @@ -739,7 +739,7 @@ function MoneyRequestConfirmationList({ return; } - if (isEditingSplitBill && TransactionUtils.areRequiredFieldsEmpty(transaction ?? null)) { + if (isEditingSplitBill && TransactionUtils.areRequiredFieldsEmpty(transaction)) { setDidConfirmSplit(true); setFormError('iou.error.genericSmartscanFailureMessage'); return; @@ -861,8 +861,8 @@ function MoneyRequestConfirmationList({ style={[styles.moneyRequestMenuItem, styles.mt2]} titleStyle={styles.moneyRequestConfirmationAmount} disabled={didConfirm} - brickRoadIndicator={shouldDisplayFieldError && TransactionUtils.isAmountMissing(transaction ?? null) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} - errorText={shouldDisplayFieldError && TransactionUtils.isAmountMissing(transaction ?? null) ? translate('common.error.enterAmount') : ''} + brickRoadIndicator={shouldDisplayFieldError && TransactionUtils.isAmountMissing(transaction) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} + errorText={shouldDisplayFieldError && TransactionUtils.isAmountMissing(transaction) ? translate('common.error.enterAmount') : ''} /> ), shouldShow: shouldShowSmartScanFields, @@ -1096,7 +1096,7 @@ function MoneyRequestConfirmationList({ isThumbnail, fileExtension, isLocalFile, - } = receiptPath && receiptFilename ? ReceiptUtils.getThumbnailAndImageURIs(transaction ?? null, receiptPath, receiptFilename) : ({} as ReceiptUtils.ThumbnailAndImageURI); + } = receiptPath && receiptFilename ? ReceiptUtils.getThumbnailAndImageURIs(transaction, receiptPath, receiptFilename) : ({} as ReceiptUtils.ThumbnailAndImageURI); const resolvedThumbnail = isLocalFile ? receiptThumbnail : tryResolveUrlFromApiRoot(receiptThumbnail ?? ''); const resolvedReceiptImage = isLocalFile ? receiptImage : tryResolveUrlFromApiRoot(receiptImage ?? ''); diff --git a/src/components/MoneyRequestHeader.tsx b/src/components/MoneyRequestHeader.tsx index e5a61a8fa23c..5d2395d4a8ec 100644 --- a/src/components/MoneyRequestHeader.tsx +++ b/src/components/MoneyRequestHeader.tsx @@ -1,5 +1,5 @@ import type {ReactNode} from 'react'; -import React, {useCallback, useEffect, useState} from 'react'; +import React, {useCallback, useEffect, useRef, useState} from 'react'; import {View} from 'react-native'; import {useOnyx} from 'react-native-onyx'; import type {OnyxEntry} from 'react-native-onyx'; @@ -18,6 +18,7 @@ import * as TransactionActions from '@userActions/Transaction'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; +import type {Route} from '@src/ROUTES'; import type {Policy, Report, ReportAction} from '@src/types/onyx'; import type {OriginalMessageIOU} from '@src/types/onyx/OriginalMessage'; import type IconAsset from '@src/types/utils/IconAsset'; @@ -67,7 +68,9 @@ function MoneyRequestHeader({report, parentReportAction, policy, shouldUseNarrow const isDuplicate = TransactionUtils.isDuplicate(transaction?.transactionID ?? ''); const {isSmallScreenWidth, windowWidth} = useWindowDimensions(); - // Only the requestor can take delete the expense, admins can only edit it. + const navigateBackToAfterDelete = useRef(); + + // Only the requestor can take delete the request, admins can only edit it. const isActionOwner = typeof parentReportAction?.actorAccountID === 'number' && typeof session?.accountID === 'number' && parentReportAction.actorAccountID === session?.accountID; const isPolicyAdmin = policy?.role === CONST.POLICY.ROLE.ADMIN; const isApprover = ReportUtils.isMoneyRequestReport(moneyRequestReport) && moneyRequestReport?.managerID !== null && session?.accountID === moneyRequestReport?.managerID; @@ -78,10 +81,10 @@ function MoneyRequestHeader({report, parentReportAction, policy, shouldUseNarrow if (parentReportAction) { const iouTransactionID = parentReportAction.actionName === CONST.REPORT.ACTIONS.TYPE.IOU ? parentReportAction.originalMessage?.IOUTransactionID ?? '' : ''; if (ReportActionsUtils.isTrackExpenseAction(parentReportAction)) { - IOU.deleteTrackExpense(parentReport?.reportID ?? '', iouTransactionID, parentReportAction, true); - return; + navigateBackToAfterDelete.current = IOU.deleteTrackExpense(parentReport?.reportID ?? '', iouTransactionID, parentReportAction, true); + } else { + navigateBackToAfterDelete.current = IOU.deleteMoneyRequest(iouTransactionID, parentReportAction, true); } - IOU.deleteMoneyRequest(iouTransactionID, parentReportAction, true); } setIsDeleteModalVisible(false); @@ -273,6 +276,12 @@ function MoneyRequestHeader({report, parentReportAction, policy, shouldUseNarrow isVisible={isDeleteModalVisible} onConfirm={deleteTransaction} onCancel={() => setIsDeleteModalVisible(false)} + onModalHide={() => { + if (!navigateBackToAfterDelete.current) { + return; + } + Navigation.goBack(navigateBackToAfterDelete.current); + }} prompt={translate('iou.deleteConfirmation')} confirmText={translate('common.delete')} cancelText={translate('common.cancel')} diff --git a/src/components/OptionsPicker/index.tsx b/src/components/OptionsPicker/index.tsx index 621b8465adba..06df1c0172c3 100644 --- a/src/components/OptionsPicker/index.tsx +++ b/src/components/OptionsPicker/index.tsx @@ -1,6 +1,7 @@ import React, {Fragment} from 'react'; import type {StyleProp, ViewStyle} from 'react-native'; import {View} from 'react-native'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; import type {TranslationPaths} from '@src/languages/types'; import type IconAsset from '@src/types/utils/IconAsset'; @@ -36,6 +37,26 @@ type OptionsPickerProps = { function OptionsPicker({options, selectedOption, onOptionSelected, style, isDisabled}: OptionsPickerProps) { const styles = useThemeStyles(); + const {shouldUseNarrowLayout} = useResponsiveLayout(); + + if (shouldUseNarrowLayout) { + return ( + + {options.map((option, index) => ( + + onOptionSelected(option.key)} + /> + {index < options.length - 1 && } + + ))} + + ); + } return ( diff --git a/src/components/PopoverMenu.tsx b/src/components/PopoverMenu.tsx index 0bdf83bf4f27..1ed819ca853b 100644 --- a/src/components/PopoverMenu.tsx +++ b/src/components/PopoverMenu.tsx @@ -12,6 +12,7 @@ import CONST from '@src/CONST'; import type {AnchorPosition} from '@src/styles'; import type AnchorAlignment from '@src/types/utils/AnchorAlignment'; import FocusableMenuItem from './FocusableMenuItem'; +import FocusTrapForModal from './FocusTrap/FocusTrapForModal'; import * as Expensicons from './Icon/Expensicons'; import type {MenuItemProps} from './MenuItem'; import MenuItem from './MenuItem'; @@ -210,39 +211,41 @@ function PopoverMenu({ shouldSetModalVisibility={shouldSetModalVisibility} shouldEnableNewFocusManagement={shouldEnableNewFocusManagement} > - - {!!headerText && enteredSubMenuIndexes.length === 0 && {headerText}} - {enteredSubMenuIndexes.length > 0 && renderBackButtonItem()} - {currentMenuItems.map((item, menuIndex) => ( - selectItem(menuIndex)} - focused={focusedIndex === menuIndex} - displayInDefaultIconColor={item.displayInDefaultIconColor} - shouldShowRightIcon={item.shouldShowRightIcon} - iconRight={item.iconRight} - shouldPutLeftPaddingWhenNoIcon={item.shouldPutLeftPaddingWhenNoIcon} - label={item.label} - isLabelHoverable={item.isLabelHoverable} - floatRightAvatars={item.floatRightAvatars} - floatRightAvatarSize={item.floatRightAvatarSize} - shouldShowSubscriptRightAvatar={item.shouldShowSubscriptRightAvatar} - disabled={item.disabled} - onFocus={() => setFocusedIndex(menuIndex)} - success={item.success} - containerStyle={item.containerStyle} - /> - ))} - + + + {!!headerText && enteredSubMenuIndexes.length === 0 && {headerText}} + {enteredSubMenuIndexes.length > 0 && renderBackButtonItem()} + {currentMenuItems.map((item, menuIndex) => ( + selectItem(menuIndex)} + focused={focusedIndex === menuIndex} + displayInDefaultIconColor={item.displayInDefaultIconColor} + shouldShowRightIcon={item.shouldShowRightIcon} + iconRight={item.iconRight} + shouldPutLeftPaddingWhenNoIcon={item.shouldPutLeftPaddingWhenNoIcon} + label={item.label} + isLabelHoverable={item.isLabelHoverable} + floatRightAvatars={item.floatRightAvatars} + floatRightAvatarSize={item.floatRightAvatarSize} + shouldShowSubscriptRightAvatar={item.shouldShowSubscriptRightAvatar} + disabled={item.disabled} + onFocus={() => setFocusedIndex(menuIndex)} + success={item.success} + containerStyle={item.containerStyle} + /> + ))} + + ); } diff --git a/src/components/Pressable/GenericPressable/BaseGenericPressable.tsx b/src/components/Pressable/GenericPressable/BaseGenericPressable.tsx index cf960df5c2cb..e866f76e1237 100644 --- a/src/components/Pressable/GenericPressable/BaseGenericPressable.tsx +++ b/src/components/Pressable/GenericPressable/BaseGenericPressable.tsx @@ -86,12 +86,11 @@ function GenericPressable( if (shouldUseHapticsOnLongPress) { HapticFeedback.longPress(); } - if (ref && 'current' in ref) { + if (ref && 'current' in ref && nextFocusRef) { ref.current?.blur(); + Accessibility.moveAccessibilityFocus(nextFocusRef); } onLongPress(event); - - Accessibility.moveAccessibilityFocus(nextFocusRef); }, [shouldUseHapticsOnLongPress, onLongPress, nextFocusRef, ref, isDisabled], ); @@ -107,11 +106,11 @@ function GenericPressable( if (shouldUseHapticsOnPress) { HapticFeedback.press(); } - if (ref && 'current' in ref) { + if (ref && 'current' in ref && nextFocusRef) { ref.current?.blur(); + Accessibility.moveAccessibilityFocus(nextFocusRef); } const onPressResult = onPress(event); - Accessibility.moveAccessibilityFocus(nextFocusRef); return onPressResult; }, [shouldUseHapticsOnPress, onPress, nextFocusRef, ref, isDisabled], diff --git a/src/components/ProcessMoneyReportHoldMenu.tsx b/src/components/ProcessMoneyReportHoldMenu.tsx index 6e81c9d57bc8..01896fb0a3cb 100644 --- a/src/components/ProcessMoneyReportHoldMenu.tsx +++ b/src/components/ProcessMoneyReportHoldMenu.tsx @@ -4,11 +4,15 @@ import useLocalize from '@hooks/useLocalize'; import Navigation from '@libs/Navigation/Navigation'; import {isLinkedTransactionHeld} from '@libs/ReportActionsUtils'; import * as IOU from '@userActions/IOU'; +import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; import type * as OnyxTypes from '@src/types/onyx'; import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage'; +import type DeepValueOf from '@src/types/utils/DeepValueOf'; import DecisionModal from './DecisionModal'; +type ActionHandledType = DeepValueOf; + type ProcessMoneyReportHoldMenuProps = { /** The chat report this report is linked to */ chatReport: OnyxEntry; @@ -35,7 +39,7 @@ type ProcessMoneyReportHoldMenuProps = { paymentType?: PaymentMethodType; /** Type of action handled */ - requestType?: 'pay' | 'approve'; + requestType?: ActionHandledType; }; function ProcessMoneyReportHoldMenu({ @@ -50,7 +54,7 @@ function ProcessMoneyReportHoldMenu({ moneyRequestReport, }: ProcessMoneyReportHoldMenuProps) { const {translate} = useLocalize(); - const isApprove = requestType === 'approve'; + const isApprove = requestType === CONST.IOU.REPORT_ACTION_TYPE.APPROVE; const onSubmit = (full: boolean) => { if (isApprove) { @@ -82,3 +86,4 @@ function ProcessMoneyReportHoldMenu({ ProcessMoneyReportHoldMenu.displayName = 'ProcessMoneyReportHoldMenu'; export default ProcessMoneyReportHoldMenu; +export type {ActionHandledType}; diff --git a/src/components/ReceiptImage.tsx b/src/components/ReceiptImage.tsx index 65eb9b82ecdc..946856ecec37 100644 --- a/src/components/ReceiptImage.tsx +++ b/src/components/ReceiptImage.tsx @@ -76,9 +76,6 @@ type ReceiptImageProps = ( /** The colod of the fallback icon */ fallbackIconColor?: string; - - /** Whether the component is hovered */ - isHovered?: boolean; }; function ReceiptImage({ @@ -96,7 +93,6 @@ function ReceiptImage({ fallbackIconSize, shouldUseInitialObjectPosition = false, fallbackIconColor, - isHovered = false, }: ReceiptImageProps) { const styles = useThemeStyles(); @@ -134,7 +130,6 @@ function ReceiptImage({ fallbackIconSize={fallbackIconSize} fallbackIconColor={fallbackIconColor} objectPosition={shouldUseInitialObjectPosition ? CONST.IMAGE_OBJECT_POSITION.INITIAL : CONST.IMAGE_OBJECT_POSITION.TOP} - isHovered={isHovered} /> ); } diff --git a/src/components/ReportActionItem/MoneyRequestAction.tsx b/src/components/ReportActionItem/MoneyRequestAction.tsx index 4f91b2084b45..fd82e723c6b9 100644 --- a/src/components/ReportActionItem/MoneyRequestAction.tsx +++ b/src/components/ReportActionItem/MoneyRequestAction.tsx @@ -10,7 +10,6 @@ import * as IOUUtils from '@libs/IOUUtils'; import Navigation from '@libs/Navigation/Navigation'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; import type {ContextMenuAnchor} from '@pages/home/report/ContextMenu/ReportActionContextMenu'; -import * as Report from '@userActions/Report'; import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -92,7 +91,6 @@ function MoneyRequestAction({ } const childReportID = action?.childReportID ?? '0'; - Report.openReport(childReportID); Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(childReportID)); }; diff --git a/src/components/ReportActionItem/MoneyRequestPreview/MoneyRequestPreviewContent.tsx b/src/components/ReportActionItem/MoneyRequestPreview/MoneyRequestPreviewContent.tsx index a35b74a2a7b3..2306f6a696f2 100644 --- a/src/components/ReportActionItem/MoneyRequestPreview/MoneyRequestPreviewContent.tsx +++ b/src/components/ReportActionItem/MoneyRequestPreview/MoneyRequestPreviewContent.tsx @@ -165,11 +165,7 @@ function MoneyRequestPreviewContent({ const isTooLong = violationsCount > 1 || violationMessage.length > 15; const hasViolationsAndFieldErrors = violationsCount > 0 && hasFieldErrors; - message += ` ${CONST.DOT_SEPARATOR} ${isTooLong || hasViolationsAndFieldErrors ? translate('violations.reviewRequired') : violationMessage}`; - if (shouldShowHoldMessage) { - message += ` ${CONST.DOT_SEPARATOR} ${translate('iou.hold')}`; - } - return message; + return `${message} ${CONST.DOT_SEPARATOR} ${isTooLong || hasViolationsAndFieldErrors ? translate('violations.reviewRequired') : violationMessage}`; } const isMerchantMissing = TransactionUtils.isMerchantMissing(transaction); diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx index 12a325f6aa19..cc99a3f6e108 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -171,7 +171,8 @@ function MoneyRequestView({ const {getViolationsForField} = useViolations(transactionViolations ?? []); const hasViolations = useCallback( - (field: ViolationField, data?: OnyxTypes.TransactionViolation['data']): boolean => !!canUseViolations && getViolationsForField(field, data).length > 0, + (field: ViolationField, data?: OnyxTypes.TransactionViolation['data'], policyHasDependentTags = false, tagValue?: string): boolean => + !!canUseViolations && getViolationsForField(field, data, policyHasDependentTags, tagValue).length > 0, [canUseViolations, getViolationsForField], ); @@ -238,7 +239,7 @@ function MoneyRequestView({ const getPendingFieldAction = (fieldPath: TransactionPendingFieldsKey) => transaction?.pendingFields?.[fieldPath] ?? pendingAction; const getErrorForField = useCallback( - (field: ViolationField, data?: OnyxTypes.TransactionViolation['data']) => { + (field: ViolationField, data?: OnyxTypes.TransactionViolation['data'], policyHasDependentTags = false, tagValue?: string) => { // Checks applied when creating a new expense // NOTE: receipt field can return multiple violations, so we need to handle it separately const fieldChecks: Partial> = { @@ -264,14 +265,14 @@ function MoneyRequestView({ } // Return violations if there are any - if (canUseViolations && hasViolations(field, data)) { - const violations = getViolationsForField(field, data); + if (hasViolations(field, data, policyHasDependentTags, tagValue)) { + const violations = getViolationsForField(field, data, policyHasDependentTags, tagValue); return ViolationsUtils.getViolationTranslation(violations[0], translate); } return ''; }, - [transactionAmount, isSettled, isCancelled, isPolicyExpenseChat, isEmptyMerchant, transactionDate, hasErrors, canUseViolations, hasViolations, translate, getViolationsForField], + [transactionAmount, isSettled, isCancelled, isPolicyExpenseChat, isEmptyMerchant, transactionDate, hasErrors, hasViolations, translate, getViolationsForField], ); const distanceRequestFields = canUseP2PDistanceRequests ? ( @@ -333,6 +334,37 @@ function MoneyRequestView({ ...parentReportAction?.errors, }; + const tagList = policyTagLists.map(({name, orderWeight}, index) => { + const tagError = getErrorForField( + 'tag', + { + tagListIndex: index, + tagListName: name, + }, + PolicyUtils.hasDependentTags(policy, policyTagList), + TransactionUtils.getTagForDisplay(transaction, index), + ); + return ( + + + Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_TAG.getRoute(CONST.IOU.ACTION.EDIT, iouType, orderWeight, transaction?.transactionID ?? '', report.reportID)) + } + brickRoadIndicator={tagError ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} + errorText={tagError} + /> + + ); + }); + return ( {shouldShowAnimatedBackground && } @@ -468,35 +500,7 @@ function MoneyRequestView({ /> )} - {shouldShowTag && - policyTagLists.map(({name, orderWeight}, index) => ( - - - Navigation.navigate( - ROUTES.MONEY_REQUEST_STEP_TAG.getRoute(CONST.IOU.ACTION.EDIT, iouType, orderWeight, transaction?.transactionID ?? '', report.reportID), - ) - } - brickRoadIndicator={ - getErrorForField('tag', { - tagListIndex: index, - tagListName: name, - }) - ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR - : undefined - } - errorText={getErrorForField('tag', {tagListIndex: index, tagListName: name})} - /> - - ))} + {shouldShowTag && tagList} {isCardTransaction && ( (); + const [nonHeldAmount, fullAmount] = ReportUtils.getNonHeldAndFullAmount(iouReport, policy); + const {isSmallScreenWidth} = useWindowDimensions(); + const [paymentType, setPaymentType] = useState(); + const managerID = iouReport?.managerID ?? 0; const {totalDisplaySpend, reimbursableSpend} = ReportUtils.getMoneyRequestSpendBreakdown(iouReport); @@ -164,6 +173,32 @@ function ReportPreview({ [chatReport?.isOwnPolicyExpenseChat, policy?.harvesting?.enabled], ); + const confirmPayment = (type: PaymentMethodType | undefined) => { + if (!type) { + return; + } + setPaymentType(type); + setRequestType(CONST.IOU.REPORT_ACTION_TYPE.PAY); + if (ReportUtils.hasHeldExpenses(iouReport?.reportID)) { + setIsHoldMenuVisible(true); + } else if (chatReport && iouReport) { + if (ReportUtils.isInvoiceReport(iouReport)) { + IOU.payInvoice(type, chatReport, iouReport); + } else { + IOU.payMoneyRequest(type, chatReport, iouReport); + } + } + }; + + const confirmApproval = () => { + setRequestType(CONST.IOU.REPORT_ACTION_TYPE.APPROVE); + if (ReportUtils.hasHeldExpenses(iouReport?.reportID)) { + setIsHoldMenuVisible(true); + } else { + IOU.approveMoneyRequest(iouReport ?? {}, true); + } + }; + const getDisplayAmount = (): string => { if (totalDisplaySpend) { return CurrencyUtils.convertToDisplayString(totalDisplaySpend, iouReport?.currency); @@ -282,17 +317,6 @@ function ReportPreview({ }; }, [formattedMerchant, formattedDescription, moneyRequestComment, translate, numberOfRequests, numberOfScanningReceipts, numberOfPendingRequests]); - const confirmPayment = (paymentMethodType?: PaymentMethodType) => { - if (!paymentMethodType || !chatReport || !iouReport) { - return; - } - if (ReportUtils.isInvoiceReport(iouReport)) { - IOU.payInvoice(paymentMethodType, chatReport, iouReport); - } else { - IOU.payMoneyRequest(paymentMethodType, chatReport, iouReport); - } - }; - return ( + {isHoldMenuVisible && iouReport && requestType !== undefined && ( + setIsHoldMenuVisible(false)} + isVisible={isHoldMenuVisible} + paymentType={paymentType} + chatReport={chatReport} + moneyRequestReport={iouReport} + /> + )} ); } diff --git a/src/components/ScreenWrapper.tsx b/src/components/ScreenWrapper.tsx index 1e9cfd23f669..c597b8c741db 100644 --- a/src/components/ScreenWrapper.tsx +++ b/src/components/ScreenWrapper.tsx @@ -19,6 +19,7 @@ import type {CentralPaneNavigatorParamList, RootStackParamList} from '@libs/Navi import toggleTestToolsModal from '@userActions/TestTool'; import CONST from '@src/CONST'; import CustomDevMenu from './CustomDevMenu'; +import FocusTrapForScreens from './FocusTrap/FocusTrapForScreen'; import HeaderGap from './HeaderGap'; import KeyboardAvoidingView from './KeyboardAvoidingView'; import OfflineIndicator from './OfflineIndicator'; @@ -100,7 +101,9 @@ type ScreenWrapperProps = { shouldShowOfflineIndicatorInWideScreen?: boolean; }; -const ScreenWrapperStatusContext = createContext({didScreenTransitionEnd: false}); +type ScreenWrapperStatusContextType = {didScreenTransitionEnd: boolean}; + +const ScreenWrapperStatusContext = createContext(undefined); function ScreenWrapper( { @@ -211,7 +214,7 @@ function ScreenWrapper( // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - const isAvoidingViewportScroll = useTackInputFocus(shouldEnableMaxHeight && shouldAvoidScrollOnVirtualViewport && Browser.isMobileSafari()); + const isAvoidingViewportScroll = useTackInputFocus(shouldEnableMaxHeight && shouldAvoidScrollOnVirtualViewport && Browser.isMobileWebKit()); const contextValue = useMemo(() => ({didScreenTransitionEnd}), [didScreenTransitionEnd]); return ( @@ -239,53 +242,55 @@ function ScreenWrapper( } return ( - + - - - - - {isDevelopment && } - - { - // If props.children is a function, call it to provide the insets to the children. - typeof children === 'function' - ? children({ - insets, - safeAreaPaddingBottomStyle, - didScreenTransitionEnd, - }) - : children - } - - {isSmallScreenWidth && shouldShowOfflineIndicator && } - {!isSmallScreenWidth && shouldShowOfflineIndicatorInWideScreen && ( - - )} - - + + + + {isDevelopment && } + + { + // If props.children is a function, call it to provide the insets to the children. + typeof children === 'function' + ? children({ + insets, + safeAreaPaddingBottomStyle, + didScreenTransitionEnd, + }) + : children + } + {isSmallScreenWidth && shouldShowOfflineIndicator && } + {!isSmallScreenWidth && shouldShowOfflineIndicatorInWideScreen && ( + + )} + + + + - + ); }} diff --git a/src/components/Search.tsx b/src/components/Search.tsx index 3d295e72c09e..ac9ec485af73 100644 --- a/src/components/Search.tsx +++ b/src/components/Search.tsx @@ -66,7 +66,7 @@ function Search({query, policyIDs, sortBy, sortOrder}: SearchProps) { }, [hash, isOffline]); const isLoadingItems = (!isOffline && isLoadingOnyxValue(searchResultsMeta)) || searchResults?.data === undefined; - const isLoadingMoreItems = !isLoadingItems && searchResults?.search?.isLoading; + const isLoadingMoreItems = !isLoadingItems && searchResults?.search?.isLoading && searchResults?.search?.offset > 0; const shouldShowEmptyState = !isLoadingItems && isEmptyObject(searchResults?.data); if (isLoadingItems) { diff --git a/src/components/Section/index.tsx b/src/components/Section/index.tsx index e7cadbe73b82..49ef1e89cff9 100644 --- a/src/components/Section/index.tsx +++ b/src/components/Section/index.tsx @@ -86,6 +86,9 @@ type SectionProps = Partial & { /** The height of the icon. */ iconHeight?: number; + + /** Banner to display at the top of the section */ + banner?: ReactNode; }; function isIllustrationLottieAnimation(illustration: DotLottieAnimation | IconAsset | undefined): illustration is DotLottieAnimation { @@ -119,6 +122,7 @@ function Section({ iconWidth, iconHeight, renderSubtitle, + banner = null, }: SectionProps) { const styles = useThemeStyles(); const theme = useTheme(); @@ -133,6 +137,7 @@ function Section({ return ( + {banner} {cardLayout === CARD_LAYOUT.ICON_ON_TOP && ( ( windowSize = 5, updateCellsBatchingPeriod = 50, removeClippedSubviews = true, + shouldDelayFocus = true, }: BaseSelectionListProps, ref: ForwardedRef, ) { @@ -507,27 +508,28 @@ function BaseSelectionList( }; }, [debouncedSelectFocusedOption, shouldDebounceRowSelect]); + /** Function to focus text input */ + const focusTextInput = useCallback(() => { + if (!innerTextInputRef.current) { + return; + } + + innerTextInputRef.current.focus(); + }, []); + /** Focuses the text input when the component comes into focus and after any navigation animations finish. */ useFocusEffect( useCallback(() => { - if (!textInputAutoFocus) { - return; - } - if (shouldShowTextInput) { - focusTimeoutRef.current = setTimeout(() => { - if (!innerTextInputRef.current) { - return; - } - innerTextInputRef.current.focus(); - }, CONST.ANIMATED_TRANSITION); - } - return () => { - if (!focusTimeoutRef.current) { - return; + if (textInputAutoFocus && shouldShowTextInput) { + if (shouldDelayFocus) { + focusTimeoutRef.current = setTimeout(focusTextInput, CONST.ANIMATED_TRANSITION); + } else { + focusTextInput(); } - clearTimeout(focusTimeoutRef.current); - }; - }, [shouldShowTextInput, textInputAutoFocus]), + } + + return () => focusTimeoutRef.current && clearTimeout(focusTimeoutRef.current); + }, [shouldShowTextInput, textInputAutoFocus, shouldDelayFocus, focusTextInput]), ); const prevTextInputValue = usePrevious(textInputValue); diff --git a/src/components/SelectionList/Search/ReportListItem.tsx b/src/components/SelectionList/Search/ReportListItem.tsx index bc5a84c33b90..9adff46395e6 100644 --- a/src/components/SelectionList/Search/ReportListItem.tsx +++ b/src/components/SelectionList/Search/ReportListItem.tsx @@ -146,64 +146,61 @@ function ReportListItem({ shouldSyncFocus={shouldSyncFocus} hoverStyle={item.isSelected && styles.activeComponentBG} > - {(hovered?: boolean) => ( - - {!isLargeScreenWidth && ( - - )} - - - - - {reportItem?.reportName} - {`${reportItem.transactions.length} ${translate('search.groupedExpenses')}`} - + + {!isLargeScreenWidth && ( + + )} + + + + + {reportItem?.reportName} + {`${reportItem.transactions.length} ${translate('search.groupedExpenses')}`} - - + + + + + {isLargeScreenWidth && ( + <> + {/** We add an empty view with type style to align the total with the table header */} + + + - - {isLargeScreenWidth && ( - <> - {/** We add an empty view with type style to align the total with the table header */} - - - - - - )} - - - {reportItem.transactions.map((transaction) => ( - { - openReportInRHP(transaction); - }} - showItemHeaderOnNarrowLayout={false} - containerStyle={styles.mt3} - isHovered={hovered} - isChildListItem - /> - ))} + + )} - )} + + {reportItem.transactions.map((transaction) => ( + { + openReportInRHP(transaction); + }} + showItemHeaderOnNarrowLayout={false} + containerStyle={styles.mt3} + isChildListItem + /> + ))} + ); } diff --git a/src/components/SelectionList/Search/TransactionListItem.tsx b/src/components/SelectionList/Search/TransactionListItem.tsx index b222631b7273..ecf9264301c2 100644 --- a/src/components/SelectionList/Search/TransactionListItem.tsx +++ b/src/components/SelectionList/Search/TransactionListItem.tsx @@ -50,16 +50,13 @@ function TransactionListItem({ shouldSyncFocus={shouldSyncFocus} hoverStyle={item.isSelected && styles.activeComponentBG} > - {(hovered?: boolean) => ( - { - onSelectRow(item); - }} - isHovered={hovered} - /> - )} + { + onSelectRow(item); + }} + /> ); } diff --git a/src/components/SelectionList/Search/TransactionListItemRow.tsx b/src/components/SelectionList/Search/TransactionListItemRow.tsx index 50a34be86f61..d17d923a54e1 100644 --- a/src/components/SelectionList/Search/TransactionListItemRow.tsx +++ b/src/components/SelectionList/Search/TransactionListItemRow.tsx @@ -33,10 +33,6 @@ type TransactionCellProps = { transactionItem: TransactionListItemType; } & CellProps; -type ReceiptCellProps = { - isHovered?: boolean; -} & TransactionCellProps; - type ActionCellProps = { onButtonPress: () => void; } & CellProps; @@ -51,7 +47,6 @@ type TransactionListItemRowProps = { onButtonPress: () => void; showItemHeaderOnNarrowLayout?: boolean; containerStyle?: StyleProp; - isHovered?: boolean; isChildListItem?: boolean; }; @@ -68,7 +63,7 @@ const getTypeIcon = (type?: SearchTransactionType) => { } }; -function ReceiptCell({transactionItem, isHovered = false}: ReceiptCellProps) { +function ReceiptCell({transactionItem}: TransactionCellProps) { const theme = useTheme(); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); @@ -85,7 +80,6 @@ function ReceiptCell({transactionItem, isHovered = false}: ReceiptCellProps) { fallbackIconSize={20} fallbackIconColor={theme.icon} iconSize="x-small" - isHovered={isHovered} /> ); @@ -181,14 +175,14 @@ function TagCell({isLargeScreenWidth, showTooltip, transactionItem}: Transaction return isLargeScreenWidth ? ( ) : ( ); } @@ -209,15 +203,7 @@ function TaxCell({transactionItem, showTooltip}: TransactionCellProps) { ); } -function TransactionListItemRow({ - item, - showTooltip, - onButtonPress, - showItemHeaderOnNarrowLayout = true, - containerStyle, - isHovered = false, - isChildListItem = false, -}: TransactionListItemRowProps) { +function TransactionListItemRow({item, showTooltip, onButtonPress, showItemHeaderOnNarrowLayout = true, containerStyle, isChildListItem = false}: TransactionListItemRowProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); const {isLargeScreenWidth} = useWindowDimensions(); @@ -242,7 +228,6 @@ function TransactionListItemRow({ transactionItem={item} isLargeScreenWidth={false} showTooltip={false} - isHovered={isHovered} /> diff --git a/src/components/SettlementButton.tsx b/src/components/SettlementButton.tsx index b6e2a753c829..85954e68c5a9 100644 --- a/src/components/SettlementButton.tsx +++ b/src/components/SettlementButton.tsx @@ -1,4 +1,4 @@ -import React, {useEffect, useMemo} from 'react'; +import React, {useMemo} from 'react'; import type {GestureResponderEvent, StyleProp, ViewStyle} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import {withOnyx} from 'react-native-onyx'; @@ -8,7 +8,6 @@ import * as ReportUtils from '@libs/ReportUtils'; import playSound, {SOUNDS} from '@libs/Sound'; import * as BankAccounts from '@userActions/BankAccounts'; import * as IOU from '@userActions/IOU'; -import * as PaymentMethods from '@userActions/PaymentMethods'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Route} from '@src/ROUTES'; @@ -144,10 +143,6 @@ function SettlementButton({ const {translate} = useLocalize(); const {isOffline} = useNetwork(); - useEffect(() => { - PaymentMethods.openWalletPage(); - }, []); - const session = useSession(); const chatReport = ReportUtils.getReport(chatReportID); const isInvoiceReport = (!isEmptyObject(iouReport) && ReportUtils.isInvoiceReport(iouReport)) || false; diff --git a/src/components/ShowContextMenuContext.ts b/src/components/ShowContextMenuContext.ts index 3a996a8d2c64..7ae3ca4fb825 100644 --- a/src/components/ShowContextMenuContext.ts +++ b/src/components/ShowContextMenuContext.ts @@ -19,9 +19,9 @@ type ShowContextMenuContextProps = { const ShowContextMenuContext = createContext({ anchor: null, - report: null, - action: null, - transactionThreadReport: null, + report: undefined, + action: undefined, + transactionThreadReport: undefined, checkIfContextMenuActive: () => {}, }); diff --git a/src/components/TextInput/BaseTextInput/isTextInputFocused.ts b/src/components/TextInput/BaseTextInput/isTextInputFocused.ts new file mode 100644 index 000000000000..bf7ae45fa6f4 --- /dev/null +++ b/src/components/TextInput/BaseTextInput/isTextInputFocused.ts @@ -0,0 +1,7 @@ +import type {AnimatedTextInputRef} from '@components/RNTextInput'; +import type {BaseTextInputRef} from './types'; + +/** Checks that text input has the isFocused method and is focused. */ +export default function isTextInputFocused(textInput: React.MutableRefObject): boolean | null { + return textInput.current && 'isFocused' in textInput.current && (textInput.current as AnimatedTextInputRef).isFocused(); +} diff --git a/src/components/ThumbnailImage.tsx b/src/components/ThumbnailImage.tsx index d4a45f3d93b3..3b89b7c3a7ad 100644 --- a/src/components/ThumbnailImage.tsx +++ b/src/components/ThumbnailImage.tsx @@ -2,6 +2,7 @@ import React, {useCallback, useEffect, useState} from 'react'; import type {ImageSourcePropType, StyleProp, ViewStyle} from 'react-native'; import {View} from 'react-native'; import useNetwork from '@hooks/useNetwork'; +import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import useThumbnailDimensions from '@hooks/useThumbnailDimensions'; @@ -48,9 +49,6 @@ type ThumbnailImageProps = { /** The object position of image */ objectPosition?: ImageObjectPosition; - - /** Whether the component is hovered */ - isHovered?: boolean; }; type UpdateImageSizeParams = { @@ -69,7 +67,6 @@ function ThumbnailImage({ fallbackIconSize = variables.iconSizeSuperLarge, fallbackIconColor, objectPosition = CONST.IMAGE_OBJECT_POSITION.INITIAL, - isHovered = false, }: ThumbnailImageProps) { const styles = useThemeStyles(); const theme = useTheme(); @@ -78,6 +75,7 @@ function ThumbnailImage({ const cachedDimensions = shouldDynamicallyResize && typeof previewSourceURL === 'string' ? thumbnailDimensionsCache.get(previewSourceURL) : null; const [imageDimensions, setImageDimensions] = useState({width: cachedDimensions?.width ?? imageWidth, height: cachedDimensions?.height ?? imageHeight}); const {thumbnailDimensionsStyles} = useThumbnailDimensions(imageDimensions.width, imageDimensions.height); + const StyleUtils = useStyleUtils(); useEffect(() => { setFailedToLoad(false); @@ -110,7 +108,7 @@ function ThumbnailImage({ if (failedToLoad || previewSourceURL === '') { return ( - + { updateTargetAndMousePosition(e); if (React.isValidElement(children)) { - children.props.onMouseEnter?.(e); + const onMouseEnter: (e: MouseEvent) => void | undefined = children.props.onMouseEnter; + onMouseEnter?.(e); } }, [children, updateTargetAndMousePosition], diff --git a/src/components/Tooltip/PopoverAnchorTooltip.tsx b/src/components/Tooltip/PopoverAnchorTooltip.tsx index 792977ff7c43..693de83fa5d7 100644 --- a/src/components/Tooltip/PopoverAnchorTooltip.tsx +++ b/src/components/Tooltip/PopoverAnchorTooltip.tsx @@ -10,8 +10,14 @@ function PopoverAnchorTooltip({shouldRender = true, children, ...props}: Tooltip const isPopoverRelatedToTooltipOpen = useMemo(() => { // eslint-disable-next-line @typescript-eslint/dot-notation - const tooltipNode = tooltipRef.current?.['_childNode'] ?? null; - if (isOpen && popover?.anchorRef?.current && tooltipNode && (tooltipNode.contains(popover.anchorRef.current) || tooltipNode === popover.anchorRef.current)) { + const tooltipNode: Node | null = tooltipRef.current?.['_childNode'] ?? null; + + if ( + isOpen && + popover?.anchorRef?.current && + tooltipNode && + ((popover.anchorRef.current instanceof Node && tooltipNode.contains(popover.anchorRef.current)) || tooltipNode === popover.anchorRef.current) + ) { return true; } diff --git a/src/components/ValidateCode/JustSignedInModal.tsx b/src/components/ValidateCode/JustSignedInModal.tsx index 19e67b0c56fe..527493c778cb 100644 --- a/src/components/ValidateCode/JustSignedInModal.tsx +++ b/src/components/ValidateCode/JustSignedInModal.tsx @@ -2,7 +2,8 @@ import React from 'react'; import {View} from 'react-native'; import Icon from '@components/Icon'; import * as Expensicons from '@components/Icon/Expensicons'; -import * as Illustrations from '@components/Icon/Illustrations'; +import Lottie from '@components/Lottie'; +import LottieAnimations from '@components/LottieAnimations'; import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; import useTheme from '@hooks/useTheme'; @@ -22,10 +23,12 @@ function JustSignedInModal({is2FARequired}: JustSignedInModalProps) { - diff --git a/src/components/ValidateCode/ValidateCodeModal.tsx b/src/components/ValidateCode/ValidateCodeModal.tsx index 1e42773c2dc2..a1d5c4ff3faa 100644 --- a/src/components/ValidateCode/ValidateCodeModal.tsx +++ b/src/components/ValidateCode/ValidateCodeModal.tsx @@ -4,7 +4,8 @@ import type {OnyxEntry} from 'react-native-onyx'; import {withOnyx} from 'react-native-onyx'; import Icon from '@components/Icon'; import * as Expensicons from '@components/Icon/Expensicons'; -import * as Illustrations from '@components/Icon/Illustrations'; +import Lottie from '@components/Lottie'; +import LottieAnimations from '@components/LottieAnimations'; import Text from '@components/Text'; import TextLink from '@components/TextLink'; import useLocalize from '@hooks/useLocalize'; @@ -37,10 +38,12 @@ function ValidateCodeModal({code, accountID, session = {}}: ValidateCodeModalPro - {translate('validateCodeModal.title')} diff --git a/src/components/VideoPlayer/BaseVideoPlayer.tsx b/src/components/VideoPlayer/BaseVideoPlayer.tsx index bc7c32729c5a..8230f9132d00 100644 --- a/src/components/VideoPlayer/BaseVideoPlayer.tsx +++ b/src/components/VideoPlayer/BaseVideoPlayer.tsx @@ -245,10 +245,10 @@ function BaseVideoPlayer({ ); // update shared video elements useEffect(() => { - if (shouldUseSharedVideoElement || url !== currentlyPlayingURL || isFullScreenRef.current) { + if (shouldUseSharedVideoElement || url !== currentlyPlayingURL) { return; } - shareVideoPlayerElements(videoPlayerRef.current, videoPlayerElementParentRef.current, videoPlayerElementRef.current, isUploading); + shareVideoPlayerElements(videoPlayerRef.current, videoPlayerElementParentRef.current, videoPlayerElementRef.current, isUploading || isFullScreenRef.current); }, [currentlyPlayingURL, shouldUseSharedVideoElement, shareVideoPlayerElements, url, isUploading, isFullScreenRef]); // append shared video element to new parent (used for example in attachment modal) diff --git a/src/components/VideoPlayerContexts/PlaybackContext.tsx b/src/components/VideoPlayerContexts/PlaybackContext.tsx index 499dd2b07f67..0e5e7f993eea 100644 --- a/src/components/VideoPlayerContexts/PlaybackContext.tsx +++ b/src/components/VideoPlayerContexts/PlaybackContext.tsx @@ -51,12 +51,12 @@ function PlaybackContextProvider({children}: ChildrenProps) { ); const shareVideoPlayerElements = useCallback( - (ref: VideoWithOnFullScreenUpdate | null, parent: View | HTMLDivElement | null, child: View | HTMLDivElement | null, isUploading: boolean) => { + (ref: VideoWithOnFullScreenUpdate | null, parent: View | HTMLDivElement | null, child: View | HTMLDivElement | null, shouldNotAutoPlay: boolean) => { currentVideoPlayerRef.current = ref; setOriginalParent(parent); setSharedElement(child); // Prevents autoplay when uploading the attachment - if (!isUploading) { + if (!shouldNotAutoPlay) { playVideo(); } }, diff --git a/src/components/VideoPlayerContexts/types.ts b/src/components/VideoPlayerContexts/types.ts index 6216fd5dc85b..85dabf958d0d 100644 --- a/src/components/VideoPlayerContexts/types.ts +++ b/src/components/VideoPlayerContexts/types.ts @@ -1,6 +1,7 @@ import type {MutableRefObject} from 'react'; import type {View} from 'react-native'; import type {SharedValue} from 'react-native-reanimated'; +import type {TupleToUnion} from 'type-fest'; import type {PopoverMenuItem} from '@components/PopoverMenu'; import type {VideoWithOnFullScreenUpdate} from '@components/VideoPlayer/types'; import type WindowDimensions from '@hooks/useWindowDimensions/types'; @@ -42,6 +43,6 @@ type FullScreenContext = { type StatusCallback = (isPlaying: boolean) => void; -type PlaybackSpeed = (typeof CONST.VIDEO_PLAYER.PLAYBACK_SPEEDS)[number]; +type PlaybackSpeed = TupleToUnion; export type {PlaybackContext, VolumeContext, VideoPopoverMenuContext, FullScreenContext, StatusCallback, PlaybackSpeed}; diff --git a/src/components/WalletStatementModal/index.tsx b/src/components/WalletStatementModal/index.tsx index d469eedc9761..e2b854842163 100644 --- a/src/components/WalletStatementModal/index.tsx +++ b/src/components/WalletStatementModal/index.tsx @@ -8,7 +8,7 @@ import * as Report from '@userActions/Report'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import type {WalletStatementOnyxProps, WalletStatementProps} from './types'; +import type {WalletStatementMessage, WalletStatementOnyxProps, WalletStatementProps} from './types'; function WalletStatementModal({statementPageURL, session}: WalletStatementProps) { const styles = useThemeStyles(); @@ -18,7 +18,7 @@ function WalletStatementModal({statementPageURL, session}: WalletStatementProps) /** * Handles in-app navigation for iframe links */ - const navigate = (event: MessageEvent) => { + const navigate = (event: MessageEvent) => { if (!event.data?.type || (event.data.type !== CONST.WALLET.WEB_MESSAGE_TYPE.STATEMENT && event.data.type !== CONST.WALLET.WEB_MESSAGE_TYPE.CONCIERGE)) { return; } diff --git a/src/components/WalletStatementModal/types.ts b/src/components/WalletStatementModal/types.ts index 567202730b7d..d9a6d299da65 100644 --- a/src/components/WalletStatementModal/types.ts +++ b/src/components/WalletStatementModal/types.ts @@ -11,4 +11,9 @@ type WalletStatementProps = WalletStatementOnyxProps & { statementPageURL: string; }; -export type {WalletStatementProps, WalletStatementOnyxProps}; +type WalletStatementMessage = { + url: string; + type: string; +}; + +export type {WalletStatementProps, WalletStatementOnyxProps, WalletStatementMessage}; diff --git a/src/components/WorkspaceSwitcherButton.tsx b/src/components/WorkspaceSwitcherButton.tsx index 7f7715f6f827..f12b60ec9228 100644 --- a/src/components/WorkspaceSwitcherButton.tsx +++ b/src/components/WorkspaceSwitcherButton.tsx @@ -1,4 +1,4 @@ -import React, {useMemo, useRef} from 'react'; +import React, {memo, useMemo, useRef} from 'react'; import type {View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import useLocalize from '@hooks/useLocalize'; @@ -26,7 +26,7 @@ function WorkspaceSwitcherButton({policy}: WorkspaceSwitcherButtonProps) { const pressableRef = useRef(null); - const {source, name, type, id} = useMemo(() => { + const mainAvatar = useMemo(() => { if (!policy) { return {source: Expensicons.ExpensifyAppIcon, name: CONST.WORKSPACE_SWITCHER.NAME, type: CONST.ICON_TYPE_AVATAR}; } @@ -56,7 +56,7 @@ function WorkspaceSwitcherButton({policy}: WorkspaceSwitcherButtonProps) { > {({hovered}) => ( ({ excludedNodes: shouldExcludeTextAreaNodes ? ['TEXTAREA'] : [], @@ -67,8 +69,13 @@ export default function useArrowKeyFocusManager({ [isActive, shouldExcludeTextAreaNodes, allowHorizontalArrowKeys], ); - // eslint-disable-next-line react-hooks/exhaustive-deps - useEffect(() => onFocusedIndexChange(focusedIndex), [focusedIndex]); + useEffect(() => { + if (prevIsFocusedIndex === focusedIndex) { + return; + } + onFocusedIndexChange(focusedIndex); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [focusedIndex, prevIsFocusedIndex]); const arrowUpCallback = useCallback(() => { if (maxIndex < 0 || !isFocused) { diff --git a/src/hooks/useAutoFocusInput.ts b/src/hooks/useAutoFocusInput.ts index c1f58901cee0..2e2d9a086ab1 100644 --- a/src/hooks/useAutoFocusInput.ts +++ b/src/hooks/useAutoFocusInput.ts @@ -49,6 +49,9 @@ export default function useAutoFocusInput(): UseAutoFocusInput { const inputCallbackRef = (ref: TextInput | null) => { inputRef.current = ref; + if (isInputInitialized) { + return; + } setIsInputInitialized(true); }; diff --git a/src/hooks/useBasePopoverReactionList/index.ts b/src/hooks/useBasePopoverReactionList/index.ts index 87c9d23c6e35..90fadc85e785 100644 --- a/src/hooks/useBasePopoverReactionList/index.ts +++ b/src/hooks/useBasePopoverReactionList/index.ts @@ -22,6 +22,7 @@ export default function useBasePopoverReactionList({emojiName, emojiReactions, a emojiCodes: [], hasUserReacted: false, users: [], + isReady: false, }; } @@ -34,6 +35,7 @@ export default function useBasePopoverReactionList({emojiName, emojiReactions, a reactionCount, hasUserReacted, users, + isReady: true, }; } diff --git a/src/hooks/useMarkdownStyle.ts b/src/hooks/useMarkdownStyle.ts index d1af33aa9da5..e21e2a77268c 100644 --- a/src/hooks/useMarkdownStyle.ts +++ b/src/hooks/useMarkdownStyle.ts @@ -5,12 +5,27 @@ import FontUtils from '@styles/utils/FontUtils'; import variables from '@styles/variables'; import useTheme from './useTheme'; -function useMarkdownStyle(message: string | null = null): MarkdownStyle { +const defaultEmptyArray: Array = []; + +function useMarkdownStyle(message: string | null = null, excludeStyles: Array = defaultEmptyArray): MarkdownStyle { const theme = useTheme(); const emojiFontSize = containsOnlyEmojis(message ?? '') ? variables.fontSizeOnlyEmojis : variables.fontSizeNormal; - const markdownStyle = useMemo( + // this map is used to reset the styles that are not needed - passing undefined value can break the native side + const nonStylingDefaultValues: Record = useMemo( () => ({ + color: theme.text, + backgroundColor: 'transparent', + marginLeft: 0, + paddingLeft: 0, + borderColor: 'transparent', + borderWidth: 0, + }), + [theme], + ); + + const markdownStyle = useMemo(() => { + const styling = { syntax: { color: theme.syntax, }, @@ -59,9 +74,21 @@ function useMarkdownStyle(message: string | null = null): MarkdownStyle { color: theme.mentionText, backgroundColor: theme.mentionBG, }, - }), - [theme, emojiFontSize], - ); + }; + + if (excludeStyles.length) { + excludeStyles.forEach((key) => { + const style: Record = styling[key]; + if (style) { + Object.keys(style).forEach((styleKey) => { + style[styleKey] = nonStylingDefaultValues[styleKey] ?? style[styleKey]; + }); + } + }); + } + + return styling; + }, [theme, emojiFontSize, excludeStyles, nonStylingDefaultValues]); return markdownStyle; } diff --git a/src/hooks/usePreferredCurrency.ts b/src/hooks/usePreferredCurrency.ts new file mode 100644 index 000000000000..c801d8007749 --- /dev/null +++ b/src/hooks/usePreferredCurrency.ts @@ -0,0 +1,33 @@ +import {useMemo} from 'react'; +import {useOnyx} from 'react-native-onyx'; +import type {ValueOf} from 'type-fest'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; + +type PreferredCurrency = ValueOf; + +/** + * Get user's preferred currency in the following order: + * + * 1. Payment card currency + * 2. User's local currency (if it's a valid payment card currency) + * 3. USD (default currency) + * + */ +function usePreferredCurrency(): PreferredCurrency { + const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST); + const [session] = useOnyx(ONYXKEYS.SESSION); + const [fundList] = useOnyx(ONYXKEYS.FUND_LIST); + + const paymentCardCurrency = useMemo(() => Object.values(fundList ?? {}).find((card) => card.accountData?.additionalData?.isBillingCard)?.accountData?.currency, [fundList]); + + if (paymentCardCurrency) { + return paymentCardCurrency; + } + + const currentUserLocalCurrency = (personalDetails?.[session?.accountID ?? '-1']?.localCurrencyCode ?? CONST.PAYMENT_CARD_CURRENCY.USD) as PreferredCurrency; + + return Object.values(CONST.PAYMENT_CARD_CURRENCY).includes(currentUserLocalCurrency) ? currentUserLocalCurrency : CONST.PAYMENT_CARD_CURRENCY.USD; +} + +export default usePreferredCurrency; diff --git a/src/hooks/useStepFormSubmit.ts b/src/hooks/useStepFormSubmit.ts index d825b12a4701..883fe980a338 100644 --- a/src/hooks/useStepFormSubmit.ts +++ b/src/hooks/useStepFormSubmit.ts @@ -1,4 +1,5 @@ import {useCallback} from 'react'; +import type {TupleToUnion} from 'type-fest'; import type {FormOnyxKeys, FormOnyxValues} from '@components/Form/types'; import * as FormActions from '@userActions/FormActions'; import type {OnyxFormKey, OnyxFormValuesMapping, OnyxValues} from '@src/ONYXKEYS'; @@ -26,7 +27,7 @@ export default function useStepFormSubmit const stepValues = fieldIds.reduce((acc, key) => { acc[key] = values[key]; return acc; - }, {} as Record<(typeof fieldIds)[number], OnyxValues[T][Exclude]>); + }, {} as Record, OnyxValues[T][Exclude]>); FormActions.setDraftValues(formId, stepValues); } diff --git a/src/hooks/useSubscriptionPrice.ts b/src/hooks/useSubscriptionPrice.ts new file mode 100644 index 000000000000..0b71fe62c7c8 --- /dev/null +++ b/src/hooks/useSubscriptionPrice.ts @@ -0,0 +1,62 @@ +import {useOnyx} from 'react-native-onyx'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import usePreferredCurrency from './usePreferredCurrency'; +import useSubscriptionPlan from './useSubscriptionPlan'; + +const SUBSCRIPTION_PRICES = { + [CONST.PAYMENT_CARD_CURRENCY.USD]: { + [CONST.POLICY.TYPE.CORPORATE]: { + [CONST.SUBSCRIPTION.TYPE.ANNUAL]: 900, + [CONST.SUBSCRIPTION.TYPE.PAYPERUSE]: 1800, + }, + [CONST.POLICY.TYPE.TEAM]: { + [CONST.SUBSCRIPTION.TYPE.ANNUAL]: 500, + [CONST.SUBSCRIPTION.TYPE.PAYPERUSE]: 1000, + }, + }, + [CONST.PAYMENT_CARD_CURRENCY.AUD]: { + [CONST.POLICY.TYPE.CORPORATE]: { + [CONST.SUBSCRIPTION.TYPE.ANNUAL]: 1500, + [CONST.SUBSCRIPTION.TYPE.PAYPERUSE]: 3000, + }, + [CONST.POLICY.TYPE.TEAM]: { + [CONST.SUBSCRIPTION.TYPE.ANNUAL]: 700, + [CONST.SUBSCRIPTION.TYPE.PAYPERUSE]: 1400, + }, + }, + [CONST.PAYMENT_CARD_CURRENCY.GBP]: { + [CONST.POLICY.TYPE.CORPORATE]: { + [CONST.SUBSCRIPTION.TYPE.ANNUAL]: 700, + [CONST.SUBSCRIPTION.TYPE.PAYPERUSE]: 1400, + }, + [CONST.POLICY.TYPE.TEAM]: { + [CONST.SUBSCRIPTION.TYPE.ANNUAL]: 400, + [CONST.SUBSCRIPTION.TYPE.PAYPERUSE]: 800, + }, + }, + [CONST.PAYMENT_CARD_CURRENCY.NZD]: { + [CONST.POLICY.TYPE.CORPORATE]: { + [CONST.SUBSCRIPTION.TYPE.ANNUAL]: 1600, + [CONST.SUBSCRIPTION.TYPE.PAYPERUSE]: 3200, + }, + [CONST.POLICY.TYPE.TEAM]: { + [CONST.SUBSCRIPTION.TYPE.ANNUAL]: 800, + [CONST.SUBSCRIPTION.TYPE.PAYPERUSE]: 1600, + }, + }, +} as const; + +function useSubscriptionPrice(): number { + const preferredCurrency = usePreferredCurrency(); + const subscriptionPlan = useSubscriptionPlan(); + const [privateSubscription] = useOnyx(ONYXKEYS.NVP_PRIVATE_SUBSCRIPTION); + + if (!subscriptionPlan || !privateSubscription?.type) { + return 0; + } + + return SUBSCRIPTION_PRICES[preferredCurrency][subscriptionPlan][privateSubscription.type]; +} + +export default useSubscriptionPrice; diff --git a/src/hooks/useSyncFocus/index.ts b/src/hooks/useSyncFocus/index.ts index afc4e7e351ab..f8b3349bc223 100644 --- a/src/hooks/useSyncFocus/index.ts +++ b/src/hooks/useSyncFocus/index.ts @@ -1,7 +1,7 @@ -import {useLayoutEffect} from 'react'; +import {useContext, useLayoutEffect} from 'react'; import type {RefObject} from 'react'; import type {View} from 'react-native'; -import useScreenWrapperTranstionStatus from '@hooks/useScreenWrapperTransitionStatus'; +import {ScreenWrapperStatusContext} from '@components/ScreenWrapper'; /** * Custom React hook created to handle sync of focus on an element when the user navigates through the app with keyboard. @@ -9,7 +9,11 @@ import useScreenWrapperTranstionStatus from '@hooks/useScreenWrapperTransitionSt * To maintain consistency when an element is focused in the app, the focus() method is additionally called on the focused element to eliminate the difference between native browser focus and application focus. */ const useSyncFocus = (ref: RefObject, isFocused: boolean, shouldSyncFocus = true) => { - const {didScreenTransitionEnd} = useScreenWrapperTranstionStatus(); + // this hook can be used outside ScreenWrapperStatusContext (eg. in Popovers). So we to check if the context is present. + // If we are outside context we don't have to look at transition status + const contextValue = useContext(ScreenWrapperStatusContext); + + const didScreenTransitionEnd = contextValue ? contextValue.didScreenTransitionEnd : true; useLayoutEffect(() => { if (!isFocused || !shouldSyncFocus || !didScreenTransitionEnd) { diff --git a/src/hooks/useViolations.ts b/src/hooks/useViolations.ts index d1cc7ec70181..83c725a48db0 100644 --- a/src/hooks/useViolations.ts +++ b/src/hooks/useViolations.ts @@ -62,12 +62,12 @@ function useViolations(violations: TransactionViolation[]) { }, [violations]); const getViolationsForField = useCallback( - (field: ViolationField, data?: TransactionViolation['data']) => { + (field: ViolationField, data?: TransactionViolation['data'], policyHasDependentTags = false, tagValue?: string) => { const currentViolations = violationsByField.get(field) ?? []; // someTagLevelsRequired has special logic becase data.errorIndexes is a bit unique in how it denotes the tag list that has the violation // tagListIndex can be 0 so we compare with undefined - if (currentViolations[0]?.name === 'someTagLevelsRequired' && data?.tagListIndex !== undefined && Array.isArray(currentViolations[0]?.data?.errorIndexes)) { + if (currentViolations[0]?.name === CONST.VIOLATIONS.SOME_TAG_LEVELS_REQUIRED && data?.tagListIndex !== undefined && Array.isArray(currentViolations[0]?.data?.errorIndexes)) { return currentViolations .filter((violation) => violation.data?.errorIndexes?.includes(data?.tagListIndex ?? -1)) .map((violation) => ({ @@ -79,8 +79,28 @@ function useViolations(violations: TransactionViolation[]) { })); } + // missingTag has special logic for policies with dependent tags, because only one violation is returned for all tags + // when no tags are present, so the tag name isn't set in the violation data. That's why we add it here + if (policyHasDependentTags && currentViolations[0]?.name === CONST.VIOLATIONS.MISSING_TAG && data?.tagListName) { + return [ + { + ...currentViolations[0], + data: { + ...currentViolations[0].data, + tagName: data?.tagListName, + }, + }, + ]; + } + // tagOutOfPolicy has special logic because we have to account for multi-level tags and use tagName to find the right tag to put the violation on - if (currentViolations[0]?.name === 'tagOutOfPolicy' && data?.tagListName !== undefined && currentViolations[0]?.data?.tagName) { + if (currentViolations[0]?.name === CONST.VIOLATIONS.TAG_OUT_OF_POLICY && data?.tagListName !== undefined && currentViolations[0]?.data?.tagName) { + return currentViolations.filter((violation) => violation.data?.tagName === data?.tagListName); + } + + // allTagLevelsRequired has special logic because it is returned when one but not all the tags are set, + // so we need to return the violation for the tag fields without a tag set + if (currentViolations[0]?.name === CONST.VIOLATIONS.ALL_TAG_LEVELS_REQUIRED && tagValue) { return currentViolations.filter((violation) => violation.data?.tagName === data?.tagListName); } diff --git a/src/languages/en.ts b/src/languages/en.ts index 76e4d5d5a143..074985735c71 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -360,7 +360,7 @@ export default { errorWhileSelectingCorruptedAttachment: 'An error occurred while selecting a corrupted attachment, please try another file.', takePhoto: 'Take photo', chooseFromGallery: 'Choose from gallery', - chooseDocument: 'Choose document', + chooseDocument: 'Choose file', attachmentTooLarge: 'Attachment too large', sizeExceeded: 'Attachment size is larger than 24 MB limit.', attachmentTooSmall: 'Attachment too small', @@ -689,7 +689,6 @@ export default { finished: 'Finished', sendInvoice: ({amount}: RequestAmountParams) => `Send ${amount} invoice`, submitAmount: ({amount}: RequestAmountParams) => `submit ${amount}`, - trackAmount: ({amount}: RequestAmountParams) => `track ${amount}`, submittedAmount: ({formattedAmount, comment}: RequestedAmountMessageParams) => `submitted ${formattedAmount}${comment ? ` for ${comment}` : ''}`, trackedAmount: ({formattedAmount, comment}: RequestedAmountMessageParams) => `tracking ${formattedAmount}${comment ? ` for ${comment}` : ''}`, splitAmount: ({amount}: SplitAmountParams) => `split ${amount}`, @@ -1622,6 +1621,8 @@ export default { facialScan: 'Onfido’s Facial Scan Policy and Release', tryAgain: 'Try again', verifyIdentity: 'Verify identity', + letsVerifyIdentity: "Let's verify your identity.", + butFirst: `But first, the boring stuff. Read up on the legalese in the next step and click "Accept" when you're ready.`, genericError: 'There was an error while processing this step. Please try again.', cameraPermissionsNotGranted: 'Enable camera access', cameraRequestMessage: 'We need access to your camera to complete bank account verification. Please enable via Settings > New Expensify.', @@ -1663,8 +1664,8 @@ export default { noOverdraftOrCredit: 'No overdraft/credit feature.', electronicFundsWithdrawal: 'Electronic funds withdrawal', standard: 'Standard', - takeALookAtSomeFees: 'Take a look at some fees.', - checkPlease: 'Check please.', + reviewTheFees: 'Please review the fees below.', + checkTheBoxes: 'Please check the boxes below.', agreeToTerms: 'Agree to the terms and you’ll be good to go!', shortTermsForm: { expensifyPaymentsAccount: ({walletProgram}: WalletProgramParams) => `The Expensify Wallet is issued by ${walletProgram}.`, @@ -2249,8 +2250,8 @@ export default { }, connectionsWarningModal: { featureEnabledTitle: 'Not so fast...', - featureEnabledText: 'To enable or disable this feature change your accounting import settings.', - disconnectText: 'Disconnect your accounting connection from the workspace if you want to disable Accounting.', + featureEnabledText: "To enable or disable this feature, you'll need to change your accounting import settings.", + disconnectText: "To disable accounting, you'll need to disconnect your accounting connection from your workspace.", manageSettings: 'Manage settings', }, }, @@ -2361,7 +2362,7 @@ export default { noVBACopy: 'Connect a bank account to issue Expensify Cards to your workspace members, and access these incredible benefits and more:', VBANoECardCopy: 'Add a work email address to issue unlimited Expensify Cards for your workspace members, as well as all of these incredible benefits:', VBAWithECardCopy: 'Access these incredible benefits and more:', - benefit1: 'Up to 2% cash back', + benefit1: 'Cash back on every US purchase', benefit2: 'Digital and physical cards', benefit3: 'No personal liability', benefit4: 'Customizable limits', @@ -3226,8 +3227,8 @@ export default { title: 'Your plan', collect: { title: 'Collect', - priceAnnual: 'From $5/active member with the Expensify Card, $10/active member without the Expensify Card.', - pricePayPerUse: 'From $10/active member with the Expensify Card, $20/active member without the Expensify Card.', + 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.`, benefit1: 'Unlimited SmartScans and distance tracking', benefit2: 'Expensify Cards with Smart Limits', benefit3: 'Bill pay and invoicing', @@ -3238,8 +3239,8 @@ export default { }, control: { title: 'Control', - priceAnnual: 'From $9/active member with the Expensify Card, $18/active member without the Expensify Card.', - pricePayPerUse: 'From $18/active member with the Expensify Card, $36/active member without the Expensify Card.', + 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.`, benefit1: 'Everything in Collect, plus:', benefit2: 'NetSuite and Sage Intacct integrations', benefit3: 'Certinia and Workday sync', @@ -3256,9 +3257,8 @@ export default { annual: 'Annual subscription', payPerUse: 'Pay-per-use', subscriptionSize: 'Subscription size', - headsUpTitle: 'Heads up: ', - headsUpBody: - "If you don’t set your subscription size now, we’ll set it automatically to your first month's active member count. You’ll then be committed to paying for at least this number of members for the next 12 months. You can increase your subscription size at any time, but you can’t decrease it until your subscription is over.", + headsUp: + "Heads up: If you don’t set your subscription size now, we’ll set it automatically to your first month's active member count. You’ll then be committed to paying for at least this number of members for the next 12 months. You can increase your subscription size at any time, but you can’t decrease it until your subscription is over.", zeroCommitment: 'Zero commitment at the discounted annual subscription rate', }, subscriptionSize: { @@ -3267,7 +3267,7 @@ export default { eachMonth: 'Each month, your subscription covers up to the number of active members set above. Any time you increase your subscription size, you’ll start a new 12-month subscription at that new size.', 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', + confirmDetails: 'Confirm your new annual subscription details:', subscriptionSize: 'Subscription size', activeMembers: ({size}) => `${size} active members/month`, subscriptionRenews: 'Subscription renews', diff --git a/src/languages/es.ts b/src/languages/es.ts index d03e13d1a9ff..93ed4853a113 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -355,7 +355,7 @@ export default { errorWhileSelectingCorruptedAttachment: 'Ha ocurrido un error al seleccionar un archivo adjunto corrupto. Por favor, inténtalo con otro archivo.', takePhoto: 'Hacer una foto', chooseFromGallery: 'Elegir de la galería', - chooseDocument: 'Elegir documento', + chooseDocument: 'Elegir un archivo', attachmentTooLarge: 'Archivo adjunto demasiado grande', sizeExceeded: 'El archivo adjunto supera el límite de 24 MB.', attachmentTooSmall: 'Archivo adjunto demasiado pequeño', @@ -683,7 +683,6 @@ export default { finished: 'Finalizado', sendInvoice: ({amount}: RequestAmountParams) => `Enviar factura de ${amount}`, submitAmount: ({amount}: RequestAmountParams) => `solicitar ${amount}`, - trackAmount: ({amount}: RequestAmountParams) => `seguimiento de ${amount}`, submittedAmount: ({formattedAmount, comment}: RequestedAmountMessageParams) => `solicitó ${formattedAmount}${comment ? ` para ${comment}` : ''}`, trackedAmount: ({formattedAmount, comment}: RequestedAmountMessageParams) => `realizó un seguimiento de ${formattedAmount}${comment ? ` para ${comment}` : ''}`, splitAmount: ({amount}: SplitAmountParams) => `dividir ${amount}`, @@ -1642,6 +1641,8 @@ export default { facialScan: 'Política y lanzamiento de la exploración facial de Onfido', tryAgain: 'Intentar otra vez', verifyIdentity: 'Verificar identidad', + letsVerifyIdentity: '¡Vamos a verificar tu identidad!', + butFirst: 'Pero primero, lo aburrido. Lee la jerga legal en el siguiente paso y haz clic en "Aceptar" cuando estés listo.', genericError: 'Hubo un error al procesar este paso. Inténtalo de nuevo.', cameraPermissionsNotGranted: 'Permiso para acceder a la cámara', cameraRequestMessage: 'Necesitamos acceso a tu cámara para completar la verificación de tu cuenta de banco. Por favor habilita los permisos en Configuración > Nuevo Expensify.', @@ -1685,8 +1686,8 @@ export default { noOverdraftOrCredit: 'Sin función de sobregiro/crédito', electronicFundsWithdrawal: 'Retiro electrónico de fondos', standard: 'Estándar', - takeALookAtSomeFees: 'Echa un vistazo a algunas tarifas.', - checkPlease: 'Por favor, revisa.', + reviewTheFees: 'Por favor, revisa las siguientes tarifas.', + checkTheBoxes: 'Por favor, marca las siguientes casillas.', agreeToTerms: 'Debes aceptar los términos y condiciones para continuar.', shortTermsForm: { expensifyPaymentsAccount: ({walletProgram}: WalletProgramParams) => `La billetera Expensify es emitida por ${walletProgram}.`, @@ -2289,7 +2290,7 @@ export default { connectionsWarningModal: { featureEnabledTitle: 'No tan rápido...', featureEnabledText: 'Para activar o desactivar esta función, cambia la configuración de importación contable.', - disconnectText: 'Desconecta tu conexión contable del espacio de trabajo si deseas desactivar la Contabilidad.', + disconnectText: 'Para desactivar la contabilidad, desconecta tu conexión contable del espacio de trabajo.', manageSettings: 'Gestionar la configuración', }, }, @@ -2538,7 +2539,7 @@ export default { VBANoECardCopy: 'Añade tu correo electrónico de trabajo para emitir Tarjetas Expensify ilimitadas para los miembros de tu espacio de trabajo y acceder a todas estas increíbles ventajas:', VBAWithECardCopy: 'Acceda a estos increíbles beneficios y más:', - benefit1: 'Hasta un 2% de devolución en tus gastos', + benefit1: 'Devolución de dinero en cada compra en Estados Unidos', benefit2: 'Tarjetas digitales y físicas', benefit3: 'Sin responsabilidad personal', benefit4: 'Límites personalizables', @@ -3733,8 +3734,8 @@ export default { title: 'Tu plan', collect: { title: 'Recolectar', - priceAnnual: 'Desde $5/miembro activo con la Tarjeta Expensify, $10/miembro activo sin la Tarjeta Expensify.', - pricePayPerUse: 'Desde $10/miembro activo con la Tarjeta Expensify, $20/miembro activo sin la Tarjeta Expensify.', + 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.`, benefit1: 'SmartScans ilimitados y seguimiento de la distancia', benefit2: 'Tarjetas Expensify con Límites Inteligentes', benefit3: 'Pago de facturas y facturación', @@ -3745,8 +3746,8 @@ export default { }, control: { title: 'Control', - priceAnnual: 'Desde $9/miembro activo con la Tarjeta Expensify, $18/miembro activo sin la Tarjeta Expensify.', - pricePayPerUse: 'Desde $18/miembro activo con la Tarjeta Expensify, $36/miembro activo sin la Tarjeta Expensify.', + 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.`, benefit1: 'Todo en Recolectar, más:', benefit2: 'Integraciones con NetSuite y Sage Intacct', benefit3: 'Sincronización de Certinia y Workday', @@ -3763,9 +3764,8 @@ export default { annual: 'Suscripción anual', payPerUse: 'Pago por uso', subscriptionSize: 'Tamaño de suscripción', - headsUpTitle: 'Atención: ', - headsUpBody: - 'Si no estableces ahora el tamaño de tu suscripción, lo haremos automáticamente con el número de suscriptores activos del primer mes. A partir de ese momento, estarás suscrito para pagar al menos por ese número de afiliados durante los 12 meses siguientes. Puedes aumentar el tamaño de tu suscripción en cualquier momento, pero no puedes reducirlo hasta que finalice tu suscripción.', + headsUp: + 'Atención: Si no estableces ahora el tamaño de tu suscripción, lo haremos automáticamente con el número de suscriptores activos del primer mes. A partir de ese momento, estarás suscrito para pagar al menos por ese número de afiliados durante los 12 meses siguientes. Puedes aumentar el tamaño de tu suscripción en cualquier momento, pero no puedes reducirlo hasta que finalice tu suscripción.', zeroCommitment: 'Compromiso cero con la tarifa de suscripción anual reducida', }, subscriptionSize: { @@ -3774,7 +3774,7 @@ export default { eachMonth: 'Cada mes, tu suscripción cubre hasta el número de miembros activos establecido anteriormente. Cada vez que aumentes el tamaño de tu suscripción, iniciarás una nueva suscripción de 12 meses con ese nuevo tamaño.', 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', + confirmDetails: 'Confirma los datos de tu nueva suscripción anual:', subscriptionSize: 'Tamaño de suscripción', activeMembers: ({size}) => `${size} miembros activos/mes`, subscriptionRenews: 'Renovación de la suscripción', diff --git a/src/languages/types.ts b/src/languages/types.ts index 5f0fc761e2de..de9b1d2dadeb 100644 --- a/src/languages/types.ts +++ b/src/languages/types.ts @@ -1,4 +1,4 @@ -import type {ReportAction} from '@src/types/onyx'; +import type {OnyxInputOrEntry, ReportAction} from '@src/types/onyx'; import type {Unit} from '@src/types/onyx/Policy'; import type en from './en'; @@ -40,15 +40,15 @@ type LocalTimeParams = { }; type EditActionParams = { - action: ReportAction | null; + action: OnyxInputOrEntry; }; type DeleteActionParams = { - action: ReportAction | null; + action: OnyxInputOrEntry; }; type DeleteConfirmationParams = { - action: ReportAction | null; + action: OnyxInputOrEntry; }; type BeginningOfChatHistoryDomainRoomPartOneParams = { @@ -299,11 +299,10 @@ type DistanceRateOperationsParams = {count: number}; type ReimbursementRateParams = {unit: Unit}; export type { - AdminCanceledRequestParams, - ApprovedAmountParams, AddressLineParams, + AdminCanceledRequestParams, AlreadySignedInParams, - UserSplitParams, + ApprovedAmountParams, BeginningOfChatHistoryAdminRoomPartOneParams, BeginningOfChatHistoryAnnounceRoomPartOneParams, BeginningOfChatHistoryAnnounceRoomPartTwo, @@ -324,8 +323,10 @@ export type { FormattedMaxLengthParams, GoBackMessageParams, GoToRoomParams, + HeldRequestParams, InstantSummaryParams, LocalTimeParams, + LogSizeParams, LoggedInAsParams, ManagerApprovedAmountParams, ManagerApprovedParams, @@ -339,11 +340,13 @@ export type { PaidElsewhereWithAmountParams, PaidWithExpensifyWithAmountParams, ParentNavigationSummaryParams, + PaySomeoneParams, PayerOwesAmountParams, PayerOwesParams, PayerPaidAmountParams, PayerPaidParams, PayerSettledParams, + ReimbursementRateParams, RemovedTheRequestParams, RenamedRoomActionParams, ReportArchiveReasonsClosedParams, @@ -375,7 +378,9 @@ export type { UntilTimeParams, UpdatedTheDistanceParams, UpdatedTheRequestParams, + UsePlusButtonParams, UserIsAlreadyMemberParams, + UserSplitParams, ViolationsAutoReportedRejectedExpenseParams, ViolationsCashExpenseWithNoReceiptParams, ViolationsConversionSurchargeParams, @@ -392,14 +397,9 @@ export type { ViolationsTaxOutOfPolicyParams, WaitingOnBankAccountParams, WalletProgramParams, - UsePlusButtonParams, WeSentYouMagicSignInLinkParams, WelcomeEnterMagicCodeParams, WelcomeNoteParams, WelcomeToRoomParams, ZipCodeExampleFormatParams, - LogSizeParams, - HeldRequestParams, - PaySomeoneParams, - ReimbursementRateParams, }; diff --git a/src/libs/API/index.ts b/src/libs/API/index.ts index bfa1b95836f8..fc4585b9ef68 100644 --- a/src/libs/API/index.ts +++ b/src/libs/API/index.ts @@ -7,6 +7,7 @@ import * as Pusher from '@libs/Pusher/pusher'; import * as Request from '@libs/Request'; import * as PersistedRequests from '@userActions/PersistedRequests'; import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; import type OnyxRequest from '@src/types/onyx/Request'; import type Response from '@src/types/onyx/Response'; import pkg from '../../../package.json'; @@ -39,6 +40,14 @@ type OnyxData = { finallyData?: OnyxUpdate[]; }; +// For all write requests, we'll send the lastUpdateID that is applied to this client. This will +// allow us to calculate previousUpdateID faster. +let lastUpdateIDAppliedToClient = 0; +Onyx.connect({ + key: ONYXKEYS.ONYX_UPDATES_LAST_UPDATE_ID_APPLIED_TO_CLIENT, + callback: (value) => (lastUpdateIDAppliedToClient = value ?? 0), +}); + /** * All calls to API.write() will be persisted to disk as JSON with the params, successData, and failureData (or finallyData, if included in place of the former two values). * This is so that if the network is unavailable or the app is closed, we can send the WRITE request later. @@ -82,6 +91,7 @@ function write(command: TCommand, apiCommandParam // This should be removed once we are no longer using deprecatedAPI https://github.com/Expensify/Expensify/issues/215650 shouldRetry: true, canCancel: true, + clientUpdateID: lastUpdateIDAppliedToClient, }, ...onyxDataWithoutOptimisticData, }; @@ -130,6 +140,7 @@ function makeRequestWithSideEffects) => !!account?.isLoading && account.loadingForm === (account.requiresTwoFactorAuth ? CONST.FORMS.VALIDATE_TFA_CODE_FORM : CONST.FORMS.VALIDATE_CODE_FORM); -export default {isValidateCodeFormSubmitting}; +const isAccountIDOddNumber = (accountID: number) => accountID % 2 === 1; + +export default {isValidateCodeFormSubmitting, isAccountIDOddNumber}; diff --git a/src/libs/CurrencyUtils.ts b/src/libs/CurrencyUtils.ts index 52530b1b3bb9..7b54fbf0bed7 100644 --- a/src/libs/CurrencyUtils.ts +++ b/src/libs/CurrencyUtils.ts @@ -129,6 +129,26 @@ function convertToDisplayString(amountInCents = 0, currency: string = CONST.CURR }); } +/** + * Given the amount in the "cents", convert it to a short string (no decimals) for display in the UI. + * The backend always handle things in "cents" (subunit equal to 1/100) + * + * @param amountInCents – should be an integer. Anything after a decimal place will be dropped. + * @param currency - IOU currency + */ +function convertToShortDisplayString(amountInCents = 0, currency: string = CONST.CURRENCY.USD): string { + const convertedAmount = convertToFrontendAmountAsInteger(amountInCents); + + return NumberFormatUtils.format(BaseLocaleListener.getPreferredLocale(), convertedAmount, { + style: 'currency', + currency, + + // There will be no decimals displayed (e.g. $9) + minimumFractionDigits: 0, + maximumFractionDigits: 0, + }); +} + /** * Given an amount, convert it to a string for display in the UI. * @@ -184,4 +204,5 @@ export { convertAmountToDisplayString, convertToDisplayStringWithoutCurrency, isValidCurrencyCode, + convertToShortDisplayString, }; diff --git a/src/libs/DateUtils.ts b/src/libs/DateUtils.ts index 50cb9a20dff6..7880e36a52e2 100644 --- a/src/libs/DateUtils.ts +++ b/src/libs/DateUtils.ts @@ -43,7 +43,7 @@ import * as CurrentDate from './actions/CurrentDate'; import * as Localize from './Localize'; import Log from './Log'; -type CustomStatusTypes = (typeof CONST.CUSTOM_STATUS_TYPES)[keyof typeof CONST.CUSTOM_STATUS_TYPES]; +type CustomStatusTypes = ValueOf; type Locale = ValueOf; type WeekDay = 0 | 1 | 2 | 3 | 4 | 5 | 6; @@ -380,7 +380,8 @@ function getDBTime(timestamp: string | number = ''): string { */ function getDBTimeWithSkew(timestamp: string | number = ''): string { if (networkTimeSkew > 0) { - return getDBTime(new Date(timestamp).valueOf() + networkTimeSkew); + const datetime = timestamp ? new Date(timestamp) : new Date(); + return getDBTime(datetime.valueOf() + networkTimeSkew); } return getDBTime(timestamp); } @@ -702,15 +703,6 @@ function formatToSupportedTimezone(timezoneInput: Timezone): Timezone { }; } -/** - * Return the date with full format if the created date is the current date. - * Otherwise return the created date. - */ -function enrichMoneyRequestTimestamp(created: string): string { - const now = new Date(); - const createdDate = parse(created, CONST.DATE.FNS_FORMAT_STRING, now); - return isSameDay(createdDate, now) ? getDBTimeFromDate(now) : created; -} /** * Returns the last business day of given date month * @@ -835,7 +827,6 @@ const DateUtils = { getWeekEndsOn, isTimeAtLeastOneMinuteInFuture, formatToSupportedTimezone, - enrichMoneyRequestTimestamp, getLastBusinessDayOfMonth, getFormattedDateRange, getFormattedReservationRangeDate, diff --git a/src/libs/DistanceRequestUtils.ts b/src/libs/DistanceRequestUtils.ts index cbae1e0d3bfb..e76f368b8f2f 100644 --- a/src/libs/DistanceRequestUtils.ts +++ b/src/libs/DistanceRequestUtils.ts @@ -4,7 +4,7 @@ import type {LocaleContextProps} from '@components/LocaleContextProvider'; import type {RateAndUnit} from '@src/CONST'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {LastSelectedDistanceRates, Report} from '@src/types/onyx'; +import type {LastSelectedDistanceRates, OnyxInputOrEntry, Report} from '@src/types/onyx'; import type {Unit} from '@src/types/onyx/Policy'; import type Policy from '@src/types/onyx/Policy'; import type {EmptyObject} from '@src/types/utils/EmptyObject'; @@ -39,7 +39,7 @@ Onyx.connect({ const METERS_TO_KM = 0.001; // 1 kilometer is 1000 meters const METERS_TO_MILES = 0.000621371; // There are approximately 0.000621371 miles in a meter -function getMileageRates(policy: OnyxEntry, includeDisabledRates = false): Record { +function getMileageRates(policy: OnyxInputOrEntry, includeDisabledRates = false): Record { const mileageRates: Record = {}; if (!policy || !policy?.customUnits) { @@ -78,14 +78,14 @@ function getMileageRates(policy: OnyxEntry, includeDisabledRates = false * @returns [currency] - The currency associated with the rate. * @returns [unit] - The unit of measurement for the distance. */ -function getDefaultMileageRate(policy: OnyxEntry | EmptyObject): MileageRate | null { +function getDefaultMileageRate(policy: OnyxInputOrEntry | EmptyObject): MileageRate | undefined { if (isEmptyObject(policy) || !policy?.customUnits) { - return null; + return undefined; } const distanceUnit = PolicyUtils.getCustomUnit(policy); if (!distanceUnit?.rates) { - return null; + return; } const mileageRates = getMileageRates(policy); @@ -252,8 +252,8 @@ function convertToDistanceInMeters(distance: number, unit: Unit): number { * Returns custom unit rate ID for the distance transaction */ function getCustomUnitRateID(reportID: string) { - const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`] ?? null; - const parentReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${report?.parentReportID}`] ?? null; + const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; + const parentReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${report?.parentReportID}`]; const policy = PolicyUtils.getPolicy(report?.policyID ?? parentReport?.policyID ?? ''); let customUnitRateID: string = CONST.CUSTOM_UNITS.FAKE_P2P_ID; diff --git a/src/libs/EmojiTrie.ts b/src/libs/EmojiTrie.ts index a8155d3f2fd6..88a1a7a275d4 100644 --- a/src/libs/EmojiTrie.ts +++ b/src/libs/EmojiTrie.ts @@ -1,3 +1,4 @@ +import type {TupleToUnion} from 'type-fest'; import emojis, {localeEmojis} from '@assets/emojis'; import type {Emoji, HeaderEmoji, PickerEmoji} from '@assets/emojis/types'; import CONST from '@src/CONST'; @@ -17,7 +18,7 @@ Timing.start(CONST.TIMING.TRIE_INITIALIZATION); const supportedLanguages = [CONST.LOCALES.DEFAULT, CONST.LOCALES.ES] as const; -type SupportedLanguage = (typeof supportedLanguages)[number]; +type SupportedLanguage = TupleToUnion; type EmojiTrie = { [key in SupportedLanguage]?: Trie; diff --git a/src/libs/EmojiUtils.ts b/src/libs/EmojiUtils.ts index f96bdd573cfe..3c7a23bf31e4 100644 --- a/src/libs/EmojiUtils.ts +++ b/src/libs/EmojiUtils.ts @@ -433,7 +433,7 @@ function suggestEmojis(text: string, lang: Locale, limit: number = CONST.AUTO_CO /** * Retrieve preferredSkinTone as Number to prevent legacy 'default' String value */ -const getPreferredSkinToneIndex = (value: string | number | null): number => { +const getPreferredSkinToneIndex = (value: OnyxEntry): number => { if (value !== null && Number.isInteger(Number(value))) { return Number(value); } diff --git a/src/libs/Environment/betaChecker/index.android.ts b/src/libs/Environment/betaChecker/index.android.ts index 258bf80aab13..b6cc8f671991 100644 --- a/src/libs/Environment/betaChecker/index.android.ts +++ b/src/libs/Environment/betaChecker/index.android.ts @@ -4,7 +4,7 @@ import * as AppUpdate from '@libs/actions/AppUpdate'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import pkg from '../../../../package.json'; -import type IsBetaBuild from './types'; +import type {IsBetaBuild} from './types'; let isLastSavedBeta = false; Onyx.connect({ diff --git a/src/libs/Environment/betaChecker/index.ios.ts b/src/libs/Environment/betaChecker/index.ios.ts index dae79dabfd2b..3220d9b00e05 100644 --- a/src/libs/Environment/betaChecker/index.ios.ts +++ b/src/libs/Environment/betaChecker/index.ios.ts @@ -1,5 +1,5 @@ import {NativeModules} from 'react-native'; -import type IsBetaBuild from './types'; +import type {IsBetaBuild} from './types'; /** * Check to see if the build is staging (TestFlight) or production diff --git a/src/libs/Environment/betaChecker/index.ts b/src/libs/Environment/betaChecker/index.ts index ce1668759c8c..eaa8bfbc1500 100644 --- a/src/libs/Environment/betaChecker/index.ts +++ b/src/libs/Environment/betaChecker/index.ts @@ -1,4 +1,4 @@ -import type IsBetaBuild from './types'; +import type {IsBetaBuild} from './types'; /** * There's no beta build in non native diff --git a/src/libs/Environment/betaChecker/types.ts b/src/libs/Environment/betaChecker/types.ts index 61ce4bc9cec4..ef205425a309 100644 --- a/src/libs/Environment/betaChecker/types.ts +++ b/src/libs/Environment/betaChecker/types.ts @@ -1,3 +1,7 @@ type IsBetaBuild = Promise; -export default IsBetaBuild; +type EnvironmentCheckerModule = { + isBeta: () => IsBetaBuild; +}; + +export type {IsBetaBuild, EnvironmentCheckerModule}; diff --git a/src/libs/ErrorUtils.ts b/src/libs/ErrorUtils.ts index 577e858f3f42..8af6b706086e 100644 --- a/src/libs/ErrorUtils.ts +++ b/src/libs/ErrorUtils.ts @@ -1,4 +1,5 @@ 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 {ErrorFields, Errors} from '@src/types/onyx/OnyxCommon'; @@ -61,7 +62,7 @@ type OnyxDataWithErrors = { errors?: Errors | null; }; -function getLatestErrorMessage(onyxData: TOnyxData | null): Localize.MaybePhraseKey { +function getLatestErrorMessage(onyxData: OnyxEntry): Localize.MaybePhraseKey { const errors = onyxData?.errors ?? {}; if (Object.keys(errors).length === 0) { @@ -72,8 +73,8 @@ function getLatestErrorMessage(onyxData: T return getErrorMessageWithTranslationData(errors[key]); } -function getLatestErrorMessageField(onyxData: TOnyxData): Errors { - const errors = onyxData.errors ?? {}; +function getLatestErrorMessageField(onyxData: OnyxEntry): Errors { + const errors = onyxData?.errors ?? {}; if (Object.keys(errors).length === 0) { return {}; @@ -88,8 +89,8 @@ type OnyxDataWithErrorFields = { errorFields?: ErrorFields; }; -function getLatestErrorField(onyxData: TOnyxData, fieldName: string): Errors { - const errorsForField = onyxData.errorFields?.[fieldName] ?? {}; +function getLatestErrorField(onyxData: OnyxEntry, fieldName: string): Errors { + const errorsForField = onyxData?.errorFields?.[fieldName] ?? {}; if (Object.keys(errorsForField).length === 0) { return {}; @@ -99,8 +100,8 @@ function getLatestErrorField(onyxData return {[key]: getErrorMessageWithTranslationData(errorsForField[key])}; } -function getEarliestErrorField(onyxData: TOnyxData, fieldName: string): Errors { - const errorsForField = onyxData.errorFields?.[fieldName] ?? {}; +function getEarliestErrorField(onyxData: OnyxEntry, fieldName: string): Errors { + const errorsForField = onyxData?.errorFields?.[fieldName] ?? {}; if (Object.keys(errorsForField).length === 0) { return {}; @@ -113,8 +114,8 @@ function getEarliestErrorField(onyxDa /** * Method used to get the latest error field for any field */ -function getLatestErrorFieldForAnyField(onyxData: TOnyxData): Errors { - const errorFields = onyxData.errorFields ?? {}; +function getLatestErrorFieldForAnyField(onyxData: OnyxEntry): Errors { + const errorFields = onyxData?.errorFields ?? {}; if (Object.keys(errorFields).length === 0) { return {}; @@ -189,9 +190,9 @@ export { getErrorMessageWithTranslationData, getErrorsWithTranslationData, getLatestErrorField, + getLatestErrorFieldForAnyField, getLatestErrorMessage, getLatestErrorMessageField, - getLatestErrorFieldForAnyField, getMicroSecondOnyxError, getMicroSecondOnyxErrorObject, isReceiptError, diff --git a/src/libs/IOUUtils.ts b/src/libs/IOUUtils.ts index 9ff4f5fb8d11..5f6d99332336 100644 --- a/src/libs/IOUUtils.ts +++ b/src/libs/IOUUtils.ts @@ -1,8 +1,7 @@ -import type {OnyxEntry} from 'react-native-onyx'; import type {IOUAction, IOUType} from '@src/CONST'; import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; -import type {Report, Transaction} from '@src/types/onyx'; +import type {OnyxInputOrEntry, Report, Transaction} from '@src/types/onyx'; import type {IOURequestType} from './actions/IOU'; import * as CurrencyUtils from './CurrencyUtils'; import Navigation from './Navigation/Navigation'; @@ -60,7 +59,7 @@ function calculateAmount(numberOfParticipants: number, total: number, currency: * @param isDeleting - whether the user is deleting the expense * @param isUpdating - whether the user is updating the expense */ -function updateIOUOwnerAndTotal>( +function updateIOUOwnerAndTotal>( iouReport: TReport, actorAccountID: number, amount: number, diff --git a/src/libs/LocaleUtils.ts b/src/libs/LocaleUtils.ts index 1a63cbf579d5..1802a26d6ffe 100644 --- a/src/libs/LocaleUtils.ts +++ b/src/libs/LocaleUtils.ts @@ -1,7 +1,7 @@ -import type {ValueOf} from 'type-fest'; +import type {TupleToUnion, ValueOf} from 'type-fest'; import CONST from '@src/CONST'; -function getLanguageFromLocale(locale: ValueOf): (typeof CONST.LANGUAGES)[number] { +function getLanguageFromLocale(locale: ValueOf): TupleToUnion { switch (locale) { case CONST.LOCALES.ES_ES: case CONST.LOCALES.ES_ES_ONFIDO: diff --git a/src/libs/Navigation/AppNavigator/AuthScreens.tsx b/src/libs/Navigation/AppNavigator/AuthScreens.tsx index a160b6765f87..410caf77e3c4 100644 --- a/src/libs/Navigation/AppNavigator/AuthScreens.tsx +++ b/src/libs/Navigation/AppNavigator/AuthScreens.tsx @@ -127,7 +127,7 @@ Onyx.connect({ Onyx.connect({ key: ONYXKEYS.ONYX_UPDATES_LAST_UPDATE_ID_APPLIED_TO_CLIENT, - callback: (value: OnyxEntry) => { + callback: (value) => { lastUpdateIDAppliedToClient = value; }, }); diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index 807c938e21dd..0577fdcfc5aa 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -205,19 +205,14 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../../pages/settings/Wallet/Card/GetPhysicalCardConfirm').default as React.ComponentType, [SCREENS.SETTINGS.WALLET.TRANSFER_BALANCE]: () => require('../../../../pages/settings/Wallet/TransferBalancePage').default as React.ComponentType, [SCREENS.SETTINGS.WALLET.CHOOSE_TRANSFER_ACCOUNT]: () => require('../../../../pages/settings/Wallet/ChooseTransferAccountPage').default as React.ComponentType, - [SCREENS.SETTINGS.WALLET.ENABLE_PAYMENTS]: () => require('../../../../pages/EnablePayments/EnablePaymentsPage').default as React.ComponentType, - // TODO: Added temporarily for testing purposes, remove after refactor - https://github.com/Expensify/App/issues/36648 - [SCREENS.SETTINGS.WALLET.ENABLE_PAYMENTS_REFACTOR]: () => require('../../../../pages/EnablePayments/PersonalInfo/PersonalInfo').default as React.ComponentType, - // TODO: Added temporarily for testing purposes, remove after refactor - https://github.com/Expensify/App/issues/36648 - [SCREENS.SETTINGS.WALLET.ENABLE_PAYMENTS_TEMPORARY_TERMS]: () => require('../../../../pages/EnablePayments/FeesAndTerms/FeesAndTerms').default as React.ComponentType, + [SCREENS.SETTINGS.WALLET.ENABLE_PAYMENTS]: () => require('../../../../pages/EnablePayments/EnablePayments').default as React.ComponentType, [SCREENS.SETTINGS.ADD_DEBIT_CARD]: () => require('../../../../pages/settings/Wallet/AddDebitCardPage').default as React.ComponentType, [SCREENS.SETTINGS.ADD_BANK_ACCOUNT]: () => require('../../../../pages/AddPersonalBankAccountPage').default as React.ComponentType, - [SCREENS.SETTINGS.ADD_BANK_ACCOUNT_REFACTOR]: () => require('../../../../pages/EnablePayments/AddBankAccount/AddBankAccount').default as React.ComponentType, [SCREENS.SETTINGS.PROFILE.STATUS]: () => require('../../../../pages/settings/Profile/CustomStatus/StatusPage').default as React.ComponentType, [SCREENS.SETTINGS.PROFILE.STATUS_CLEAR_AFTER]: () => require('../../../../pages/settings/Profile/CustomStatus/StatusClearAfterPage').default as React.ComponentType, [SCREENS.SETTINGS.PROFILE.STATUS_CLEAR_AFTER_DATE]: () => require('../../../../pages/settings/Profile/CustomStatus/SetDatePage').default as React.ComponentType, [SCREENS.SETTINGS.PROFILE.STATUS_CLEAR_AFTER_TIME]: () => require('../../../../pages/settings/Profile/CustomStatus/SetTimePage').default as React.ComponentType, - [SCREENS.SETTINGS.SUBSCRIPTION.SIZE]: () => require('../../../../pages/settings/Subscription/SubscriptionSize/SubscriptionSizePage').default as React.ComponentType, + [SCREENS.SETTINGS.SUBSCRIPTION.SIZE]: () => require('../../../../pages/settings/Subscription/SubscriptionSize').default as React.ComponentType, [SCREENS.SETTINGS.SUBSCRIPTION.DISABLE_AUTO_RENEW_SURVEY]: () => require('../../../../pages/settings/Subscription/DisableAutoRenewSurveyPage').default as React.ComponentType, [SCREENS.WORKSPACE.RATE_AND_UNIT]: () => require('../../../../pages/workspace/reimburse/WorkspaceRateAndUnitPage/InitialPage').default as React.ComponentType, [SCREENS.WORKSPACE.RATE_AND_UNIT_RATE]: () => require('../../../../pages/workspace/reimburse/WorkspaceRateAndUnitPage/RatePage').default as React.ComponentType, diff --git a/src/libs/Navigation/AppNavigator/Navigators/FullScreenNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/FullScreenNavigator.tsx index 1745e350c62d..64485872544f 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/FullScreenNavigator.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/FullScreenNavigator.tsx @@ -1,5 +1,6 @@ import React from 'react'; import {View} from 'react-native'; +import FocusTrapForScreens from '@components/FocusTrap/FocusTrapForScreen'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; @@ -14,7 +15,7 @@ const RootStack = createCustomFullScreenNavigator(); type Screens = Partial React.ComponentType>>; -const centralPaneWorkspaceScreens = { +const CENTRAL_PANE_WORKSPACE_SCREENS = { [SCREENS.WORKSPACE.PROFILE]: () => require('../../../../pages/workspace/WorkspaceProfilePage').default as React.ComponentType, [SCREENS.WORKSPACE.CARD]: () => require('../../../../pages/workspace/card/WorkspaceCardPage').default as React.ComponentType, [SCREENS.WORKSPACE.WORKFLOWS]: () => require('../../../../pages/workspace/workflows/WorkspaceWorkflowsPage').default as React.ComponentType, @@ -38,25 +39,28 @@ function FullScreenNavigator() { const screenOptions = getRootNavigatorScreenOptions(isSmallScreenWidth, styles, StyleUtils); return ( - - - - {Object.entries(centralPaneWorkspaceScreens).map(([screenName, componentGetter]) => ( + + + - ))} - - + {Object.entries(CENTRAL_PANE_WORKSPACE_SCREENS).map(([screenName, componentGetter]) => ( + + ))} + + + ); } FullScreenNavigator.displayName = 'FullScreenNavigator'; +export {CENTRAL_PANE_WORKSPACE_SCREENS}; export default FullScreenNavigator; diff --git a/src/libs/Navigation/AppNavigator/Navigators/OnboardingModalNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/OnboardingModalNavigator.tsx index d2d71a5dc2a2..9c17d5da53a5 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/OnboardingModalNavigator.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/OnboardingModalNavigator.tsx @@ -3,6 +3,7 @@ import React, {useCallback, useEffect} from 'react'; import {View} from 'react-native'; import {useOnyx} from 'react-native-onyx'; import NoDropZone from '@components/DragAndDrop/NoDropZone'; +import FocusTrapForScreens from '@components/FocusTrap/FocusTrapForScreen'; import useKeyboardShortcut from '@hooks/useKeyboardShortcut'; import useOnboardingLayout from '@hooks/useOnboardingLayout'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -59,25 +60,27 @@ function OnboardingModalNavigator() { onClick={handleOuterClick} style={styles.onboardingNavigatorOuterView} > - e.stopPropagation()} - style={styles.OnboardingNavigatorInnerView(shouldUseNarrowLayout)} - > - - - - - - + + e.stopPropagation()} + style={styles.OnboardingNavigatorInnerView(shouldUseNarrowLayout)} + > + + + + + + + ); diff --git a/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/BottomTabBar.tsx b/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/BottomTabBar/index.tsx similarity index 91% rename from src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/BottomTabBar.tsx rename to src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/BottomTabBar/index.tsx index 25bec235ed45..9b2e9449c672 100644 --- a/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/BottomTabBar.tsx +++ b/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/BottomTabBar/index.tsx @@ -1,5 +1,5 @@ import {useNavigation, useNavigationState} from '@react-navigation/native'; -import React, {useEffect} from 'react'; +import React, {memo, useCallback, useEffect} from 'react'; import {View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import {withOnyx} from 'react-native-onyx'; @@ -12,6 +12,7 @@ import useLocalize from '@hooks/useLocalize'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import * as Session from '@libs/actions/Session'; +import interceptAnonymousUser from '@libs/interceptAnonymousUser'; import getTopmostBottomTabRoute from '@libs/Navigation/getTopmostBottomTabRoute'; import getTopmostCentralPaneRoute from '@libs/Navigation/getTopmostCentralPaneRoute'; import Navigation from '@libs/Navigation/Navigation'; @@ -24,6 +25,7 @@ import * as Welcome from '@userActions/Welcome'; import CONST from '@src/CONST'; import NAVIGATORS from '@src/NAVIGATORS'; import ONYXKEYS from '@src/ONYXKEYS'; +import type {Route} from '@src/ROUTES'; import ROUTES from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; @@ -36,9 +38,8 @@ function BottomTabBar({isLoadingApp = false}: PurposeForUsingExpensifyModalProps const theme = useTheme(); const styles = useThemeStyles(); const {translate} = useLocalize(); - const {activeWorkspaceID} = useActiveWorkspace(); - const navigation = useNavigation(); + const {activeWorkspaceID} = useActiveWorkspace(); useEffect(() => { const navigationState = navigation.getState() as State | undefined; @@ -68,13 +69,16 @@ function BottomTabBar({isLoadingApp = false}: PurposeForUsingExpensifyModalProps const chatTabBrickRoad = getChatTabBrickRoad(activeWorkspaceID); + const navigateToChats = useCallback(() => { + const route = activeWorkspaceID ? (`/w/${activeWorkspaceID}/home` as Route) : ROUTES.HOME; + Navigation.navigate(route); + }, [activeWorkspaceID]); + return ( { - Navigation.navigate(ROUTES.HOME); - }} + onPress={navigateToChats} role={CONST.ROLE.BUTTON} accessibilityLabel={translate('common.inbox')} wrapperStyle={styles.flex1} @@ -96,7 +100,7 @@ function BottomTabBar({isLoadingApp = false}: PurposeForUsingExpensifyModalProps { - Navigation.navigate(ROUTES.SEARCH.getRoute(CONST.TAB_SEARCH.ALL)); + interceptAnonymousUser(() => Navigation.navigate(ROUTES.SEARCH.getRoute(CONST.TAB_SEARCH.ALL))); }} role={CONST.ROLE.BUTTON} accessibilityLabel={translate('common.search')} @@ -127,4 +131,4 @@ export default withOnyx; +}; +type PurposeForUsingExpensifyModalProps = PurposeForUsingExpensifyModalOnyxProps; + +function BottomTabBar({isLoadingApp = false}: PurposeForUsingExpensifyModalProps) { + const theme = useTheme(); + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const navigation = useNavigation(); + const {activeWorkspaceID: contextActiveWorkspaceID} = useActiveWorkspace(); + const activeWorkspaceID = sessionStorage.getItem(CONST.SESSION_STORAGE_KEYS.ACTIVE_WORKSPACE_ID) ?? contextActiveWorkspaceID; + + useEffect(() => { + const navigationState = navigation.getState() as State | undefined; + const routes = navigationState?.routes; + const currentRoute = routes?.[navigationState?.index ?? 0]; + // When we are redirected to the Settings tab from the OldDot, we don't want to call the Welcome.show() method. + // To prevent this, the value of the bottomTabRoute?.name is checked here + if (!!(currentRoute && currentRoute.name !== NAVIGATORS.BOTTOM_TAB_NAVIGATOR && currentRoute.name !== NAVIGATORS.CENTRAL_PANE_NAVIGATOR) || Session.isAnonymousUser()) { + return; + } + + Welcome.isOnboardingFlowCompleted({onNotCompleted: () => Navigation.navigate(ROUTES.ONBOARDING_ROOT)}); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isLoadingApp]); + + // Parent navigator of the bottom tab bar is the root navigator. + const currentTabName = useNavigationState((state) => { + const topmostCentralPaneRoute = getTopmostCentralPaneRoute(state); + + if (topmostCentralPaneRoute && topmostCentralPaneRoute.name === SCREENS.SEARCH.CENTRAL_PANE) { + return SCREENS.SEARCH.CENTRAL_PANE; + } + + const topmostBottomTabRoute = getTopmostBottomTabRoute(state); + return topmostBottomTabRoute?.name ?? SCREENS.HOME; + }); + + const chatTabBrickRoad = getChatTabBrickRoad(activeWorkspaceID); + + const navigateToChats = useCallback(() => { + const route = activeWorkspaceID ? (`/w/${activeWorkspaceID}/home` as Route) : ROUTES.HOME; + Navigation.navigate(route); + }, [activeWorkspaceID]); + + return ( + + + + + + {chatTabBrickRoad && ( + + )} + + + + + { + interceptAnonymousUser(() => Navigation.navigate(ROUTES.SEARCH.getRoute(CONST.TAB_SEARCH.ALL))); + }} + role={CONST.ROLE.BUTTON} + accessibilityLabel={translate('common.search')} + wrapperStyle={styles.flex1} + style={styles.bottomTabBarItem} + > + + + + + + + + + + + ); +} + +BottomTabBar.displayName = 'BottomTabBar'; + +export default withOnyx({ + isLoadingApp: { + key: ONYXKEYS.IS_LOADING_APP, + }, +})(BottomTabBar); diff --git a/src/libs/Navigation/NavigationRoot.tsx b/src/libs/Navigation/NavigationRoot.tsx index 9e1eb348451b..eaaf5eae12c0 100644 --- a/src/libs/Navigation/NavigationRoot.tsx +++ b/src/libs/Navigation/NavigationRoot.tsx @@ -10,14 +10,15 @@ import {FSPage} from '@libs/Fullstory'; import Log from '@libs/Log'; import {getPathFromURL} from '@libs/Url'; import {updateLastVisitedPath} from '@userActions/App'; +import CONST from '@src/CONST'; import type {Route} from '@src/ROUTES'; -import SCREENS from '@src/SCREENS'; import AppNavigator from './AppNavigator'; import getPolicyIDFromState from './getPolicyIDFromState'; import linkingConfig from './linkingConfig'; import customGetPathFromState from './linkingConfig/customGetPathFromState'; import getAdaptedStateFromPath from './linkingConfig/getAdaptedStateFromPath'; import Navigation, {navigationRef} from './Navigation'; +import setupCustomAndroidBackHandler from './setupCustomAndroidBackHandler'; import type {RootStackParamList} from './types'; type NavigationRootProps = { @@ -46,7 +47,7 @@ function parseAndLogRoute(state: NavigationState) { const focusedRoute = findFocusedRoute(state); - if (focusedRoute?.name !== SCREENS.NOT_FOUND && focusedRoute?.name !== SCREENS.SAML_SIGN_IN) { + if (focusedRoute && !CONST.EXCLUDE_FROM_LAST_VISITED_PATH.includes(focusedRoute?.name)) { updateLastVisitedPath(currentPath); } @@ -109,6 +110,8 @@ function NavigationRoot({authenticated, lastVisitedPath, initialUrl, onReady}: N useEffect(() => { if (firstRenderRef.current) { + setupCustomAndroidBackHandler(); + // we don't want to make the report back button go back to LHN if the user // started on the small screen so we don't set it on the first render // making it only work on consecutive changes of the screen size @@ -135,11 +138,6 @@ function NavigationRoot({authenticated, lastVisitedPath, initialUrl, onReady}: N // We want to clean saved scroll offsets for screens that aren't anymore in the state. cleanStaleScrollOffsets(state); - - // clear all window selection on navigation - // this is to prevent the selection from persisting when navigating to a new page in web - // using "?" to avoid crash in native - window?.getSelection?.()?.removeAllRanges?.(); }; return ( diff --git a/src/libs/Navigation/linkTo/index.ts b/src/libs/Navigation/linkTo/index.ts index 313f525f390b..7d6d62b9a5aa 100644 --- a/src/libs/Navigation/linkTo/index.ts +++ b/src/libs/Navigation/linkTo/index.ts @@ -1,6 +1,6 @@ import {getActionFromState} from '@react-navigation/core'; -import {findFocusedRoute} from '@react-navigation/native'; import type {NavigationContainerRef, NavigationState, PartialState} from '@react-navigation/native'; +import {findFocusedRoute} from '@react-navigation/native'; import {omitBy} from 'lodash'; import getIsNarrowLayout from '@libs/getIsNarrowLayout'; import extractPolicyIDsFromState from '@libs/Navigation/linkingConfig/extractPolicyIDsFromState'; @@ -86,9 +86,12 @@ export default function linkTo(navigation: NavigationContainerRef)?.policyID !== (matchingBottomTabRoute?.params as Record)?.policyID; - if (topmostBottomTabRoute && (topmostBottomTabRoute.name !== matchingBottomTabRoute.name || isNewPolicyID)) { + ((topmostBottomTabRoute?.params as Record)?.policyID ?? '') !== + ((matchingBottomTabRoute?.params as Record)?.policyID ?? ''); + + if (topmostBottomTabRoute && (topmostBottomTabRoute.name !== matchingBottomTabRoute.name || isNewPolicyID || isOpeningSearch)) { root.dispatch({ type: CONST.NAVIGATION.ACTION_TYPE.PUSH, payload: matchingBottomTabRoute, diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts index bb002ec2c01f..07dd45701029 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -166,16 +166,6 @@ const config: LinkingOptions['config'] = { path: ROUTES.SETTINGS_ENABLE_PAYMENTS, exact: true, }, - // TODO: Added temporarily for testing purposes, remove after refactor - https://github.com/Expensify/App/issues/36648 - [SCREENS.SETTINGS.WALLET.ENABLE_PAYMENTS_REFACTOR]: { - path: ROUTES.SETTINGS_ENABLE_PAYMENTS_REFACTOR, - exact: true, - }, - // TODO: Added temporarily for testing purposes, remove after refactor - https://github.com/Expensify/App/issues/36648 - [SCREENS.SETTINGS.WALLET.ENABLE_PAYMENTS_TEMPORARY_TERMS]: { - path: ROUTES.SETTINGS_ENABLE_PAYMENTS_TEMPORARY_TERMS, - exact: true, - }, [SCREENS.SETTINGS.WALLET.TRANSFER_BALANCE]: { path: ROUTES.SETTINGS_WALLET_TRANSFER_BALANCE, exact: true, @@ -204,10 +194,6 @@ const config: LinkingOptions['config'] = { path: ROUTES.SETTINGS_ADD_BANK_ACCOUNT, exact: true, }, - [SCREENS.SETTINGS.ADD_BANK_ACCOUNT_REFACTOR]: { - path: ROUTES.SETTINGS_ADD_BANK_ACCOUNT_REFACTOR, - exact: true, - }, [SCREENS.SETTINGS.PROFILE.PRONOUNS]: { path: ROUTES.SETTINGS_PRONOUNS, exact: true, diff --git a/src/libs/Navigation/linkingConfig/getAdaptedStateFromPath.ts b/src/libs/Navigation/linkingConfig/getAdaptedStateFromPath.ts index e6baacc1cea9..31f025c05446 100644 --- a/src/libs/Navigation/linkingConfig/getAdaptedStateFromPath.ts +++ b/src/libs/Navigation/linkingConfig/getAdaptedStateFromPath.ts @@ -1,5 +1,6 @@ import type {NavigationState, PartialState, Route} from '@react-navigation/native'; import {findFocusedRoute, getStateFromPath} from '@react-navigation/native'; +import type {TupleToUnion} from 'type-fest'; import {isAnonymousUser} from '@libs/actions/Session'; import getIsNarrowLayout from '@libs/getIsNarrowLayout'; import type {BottomTabName, CentralPaneName, FullScreenName, NavigationPartialRoute, RootStackParamList} from '@libs/Navigation/types'; @@ -17,7 +18,7 @@ import replacePathInNestedState from './replacePathInNestedState'; const RHP_SCREENS_OPENED_FROM_LHN = [SCREENS.SETTINGS.SHARE_CODE, SCREENS.SETTINGS.PROFILE.STATUS] as const; -type RHPScreenOpenedFromLHN = (typeof RHP_SCREENS_OPENED_FROM_LHN)[number]; +type RHPScreenOpenedFromLHN = TupleToUnion; type Metainfo = { // Sometimes modal screens don't have information about what should be visible under the overlay. diff --git a/src/libs/Navigation/linkingConfig/getMatchingBottomTabRouteForState.ts b/src/libs/Navigation/linkingConfig/getMatchingBottomTabRouteForState.ts index fd45685acf23..4b4ed25959f0 100644 --- a/src/libs/Navigation/linkingConfig/getMatchingBottomTabRouteForState.ts +++ b/src/libs/Navigation/linkingConfig/getMatchingBottomTabRouteForState.ts @@ -21,6 +21,15 @@ function getMatchingBottomTabRouteForState(state: State, pol } const tabName = CENTRAL_PANE_TO_TAB_MAPPING[topmostCentralPaneRoute.name]; + + if (tabName === SCREENS.SEARCH.BOTTOM_TAB) { + const topmostCentralPaneRouteParams = topmostCentralPaneRoute.params as Record; + delete topmostCentralPaneRouteParams?.policyIDs; + if (policyID) { + topmostCentralPaneRouteParams.policyID = policyID; + } + return {name: tabName, params: topmostCentralPaneRouteParams}; + } return {name: tabName, params: paramsWithPolicyID}; } diff --git a/src/libs/Navigation/setupCustomAndroidBackHandler/index.android.ts b/src/libs/Navigation/setupCustomAndroidBackHandler/index.android.ts new file mode 100644 index 000000000000..ac272155289d --- /dev/null +++ b/src/libs/Navigation/setupCustomAndroidBackHandler/index.android.ts @@ -0,0 +1,68 @@ +import {findFocusedRoute, StackActions} from '@react-navigation/native'; +import type {StackScreenProps} from '@react-navigation/stack'; +import {BackHandler} from 'react-native'; +import getTopmostCentralPaneRoute from '@navigation/getTopmostCentralPaneRoute'; +import navigationRef from '@navigation/navigationRef'; +import type {BottomTabNavigatorParamList, RootStackParamList, State} from '@navigation/types'; +import NAVIGATORS from '@src/NAVIGATORS'; +import SCREENS from '@src/SCREENS'; + +type SearchPageProps = StackScreenProps; + +// We need to do some custom handling for the back button on Android for actions related to the search page. +function setupCustomAndroidBackHandler() { + const onBackPress = () => { + const rootState = navigationRef.getRootState(); + + const bottomTabRoute = rootState.routes.find((route) => route.name === NAVIGATORS.BOTTOM_TAB_NAVIGATOR); + const bottomTabRoutes = bottomTabRoute?.state?.routes; + const focusedRoute = findFocusedRoute(rootState); + + // Shoudn't happen but for type safety. + if (!bottomTabRoutes) { + return false; + } + + // Handle back press on the search page. + // We need to pop two screens, from the central pane and from the bottom tab. + if (bottomTabRoutes[bottomTabRoutes.length - 1].name === SCREENS.SEARCH.BOTTOM_TAB && focusedRoute?.name === SCREENS.SEARCH.CENTRAL_PANE) { + navigationRef.dispatch({...StackActions.pop(), target: bottomTabRoute?.state?.key}); + navigationRef.dispatch({...StackActions.pop()}); + + const centralPaneRouteAfterPop = getTopmostCentralPaneRoute({routes: [rootState.routes.at(-2)]} as State); + const bottomTabRouteAfterPop = bottomTabRoutes.at(-2); + + // It's possible that central pane search is desynchronized with the bottom tab search. + // e.g. opening a tab different than search will wipe out central pane screens. + // In that case we have to push the proper one. + if ( + bottomTabRouteAfterPop && + bottomTabRouteAfterPop.name === SCREENS.SEARCH.BOTTOM_TAB && + (!centralPaneRouteAfterPop || centralPaneRouteAfterPop.name !== SCREENS.SEARCH.CENTRAL_PANE) + ) { + const {policyID, ...restParams} = bottomTabRoutes[bottomTabRoutes.length - 2].params as SearchPageProps['route']['params']; + navigationRef.dispatch({...StackActions.push(NAVIGATORS.CENTRAL_PANE_NAVIGATOR, {screen: SCREENS.SEARCH.CENTRAL_PANE, params: {...restParams, policyIDs: policyID}})}); + } + + return true; + } + + // Handle back press to go back to the search page. + // It's possible that central pane search is desynchronized with the bottom tab search. + // e.g. opening a tab different than search will wipe out central pane screens. + // In that case we have to push the proper one. + if (bottomTabRoutes && bottomTabRoutes?.length >= 2 && bottomTabRoutes[bottomTabRoutes.length - 2].name === SCREENS.SEARCH.BOTTOM_TAB && rootState.routes.length === 1) { + const {policyID, ...restParams} = bottomTabRoutes[bottomTabRoutes.length - 2].params as SearchPageProps['route']['params']; + navigationRef.dispatch({...StackActions.push(NAVIGATORS.CENTRAL_PANE_NAVIGATOR, {screen: SCREENS.SEARCH.CENTRAL_PANE, params: {...restParams, policyIDs: policyID}})}); + navigationRef.dispatch({...StackActions.pop(), target: bottomTabRoute?.state?.key}); + return true; + } + + // Handle all other cases with default handler. + return false; + }; + + BackHandler.addEventListener('hardwareBackPress', onBackPress); +} + +export default setupCustomAndroidBackHandler; diff --git a/src/libs/Navigation/setupCustomAndroidBackHandler/index.ts b/src/libs/Navigation/setupCustomAndroidBackHandler/index.ts new file mode 100644 index 000000000000..aa9077e1220f --- /dev/null +++ b/src/libs/Navigation/setupCustomAndroidBackHandler/index.ts @@ -0,0 +1,4 @@ +// Do nothing for platforms different than Android. +function setupCustomAndroidBackHandler() {} + +export default setupCustomAndroidBackHandler; diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index 5597e7ce00da..e87f0380a2da 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -854,7 +854,13 @@ type WelcomeVideoModalNavigatorParamList = { type BottomTabNavigatorParamList = { [SCREENS.HOME]: {policyID?: string}; - [SCREENS.SEARCH.BOTTOM_TAB]: {policyID?: string}; + [SCREENS.SEARCH.BOTTOM_TAB]: { + query: string; + policyID?: string; + offset?: number; + sortBy?: SearchColumnType; + sortOrder?: SortOrder; + }; [SCREENS.SETTINGS.ROOT]: {policyID?: string}; }; diff --git a/src/libs/Network/NetworkStore.ts b/src/libs/Network/NetworkStore.ts index 6b3ea2e82225..dd1290b6565e 100644 --- a/src/libs/Network/NetworkStore.ts +++ b/src/libs/Network/NetworkStore.ts @@ -5,8 +5,8 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type Credentials from '@src/types/onyx/Credentials'; -let credentials: Credentials | null = null; -let authToken: string | null = null; +let credentials: Credentials | null | undefined; +let authToken: string | null | undefined; let authTokenType: ValueOf | null; let currentUserEmail: string | null = null; let offline = false; @@ -62,7 +62,7 @@ Onyx.connect({ Onyx.connect({ key: ONYXKEYS.CREDENTIALS, callback: (val) => { - credentials = val; + credentials = val ?? null; checkRequiredData(); }, }); @@ -85,7 +85,7 @@ Onyx.connect({ }, }); -function getCredentials(): Credentials | null { +function getCredentials(): Credentials | null | undefined { return credentials; } @@ -93,7 +93,7 @@ function isOffline(): boolean { return offline; } -function getAuthToken(): string | null { +function getAuthToken(): string | null | undefined { return authToken; } diff --git a/src/libs/Network/SequentialQueue.ts b/src/libs/Network/SequentialQueue.ts index bf68a694523a..10df3c703c92 100644 --- a/src/libs/Network/SequentialQueue.ts +++ b/src/libs/Network/SequentialQueue.ts @@ -1,5 +1,6 @@ import Onyx from 'react-native-onyx'; import * as ActiveClientManager from '@libs/ActiveClientManager'; +import Log from '@libs/Log'; import * as Request from '@libs/Request'; import * as RequestThrottle from '@libs/RequestThrottle'; import * as PersistedRequests from '@userActions/PersistedRequests'; @@ -26,10 +27,11 @@ let isQueuePaused = false; */ function pause() { if (isQueuePaused) { + Log.info('[SequentialQueue] Queue already paused'); return; } - console.debug('[SequentialQueue] Pausing the queue'); + Log.info('[SequentialQueue] Pausing the queue'); isQueuePaused = true; } @@ -40,6 +42,7 @@ function flushOnyxUpdatesQueue() { // The only situation where the queue is paused is if we found a gap between the app current data state and our server's. If that happens, // we'll trigger async calls to make the client updated again. While we do that, we don't want to insert anything in Onyx. if (isQueuePaused) { + Log.info('[SequentialQueue] Queue already paused'); return; } QueuedOnyxUpdates.flushQueue(); @@ -56,11 +59,18 @@ function flushOnyxUpdatesQueue() { function process(): Promise { // When the queue is paused, return early. This prevents any new requests from happening. The queue will be flushed again when the queue is unpaused. if (isQueuePaused) { + Log.info('[SequentialQueue] Unable to process. Queue is paused.'); + return Promise.resolve(); + } + + if (NetworkStore.isOffline()) { + Log.info('[SequentialQueue] Unable to process. We are offline.'); return Promise.resolve(); } const persistedRequests = PersistedRequests.getAll(); - if (persistedRequests.length === 0 || NetworkStore.isOffline()) { + if (persistedRequests.length === 0) { + Log.info('[SequentialQueue] Unable to process. No requests to process.'); return Promise.resolve(); } @@ -72,6 +82,7 @@ function process(): Promise { // A response might indicate that the queue should be paused. This happens when a gap in onyx updates is detected between the client and the server and // that gap needs resolved before the queue can continue. if (response?.shouldPauseQueue) { + Log.info("[SequentialQueue] Handled 'shouldPauseQueue' in response. Pausing the queue."); pause(); } PersistedRequests.remove(requestToProcess); @@ -102,16 +113,24 @@ function process(): Promise { function flush() { // When the queue is paused, return early. This will keep an requests in the queue and they will get flushed again when the queue is unpaused if (isQueuePaused) { + Log.info('[SequentialQueue] Unable to flush. Queue is paused.'); + return; + } + + if (isSequentialQueueRunning) { + Log.info('[SequentialQueue] Unable to flush. Queue is already running.'); return; } - if (isSequentialQueueRunning || PersistedRequests.getAll().length === 0) { + if (PersistedRequests.getAll().length === 0) { + Log.info('[SequentialQueue] Unable to flush. No requests to process.'); return; } // ONYXKEYS.PERSISTED_REQUESTS is shared across clients, thus every client/tab will have a copy // It is very important to only process the queue from leader client otherwise requests will be duplicated. if (!ActiveClientManager.isClientTheLeader()) { + Log.info('[SequentialQueue] Unable to flush. Client is not the leader.'); return; } @@ -128,6 +147,7 @@ function flush() { callback: () => { Onyx.disconnect(connectionID); process().finally(() => { + Log.info('[SequentialQueue] Finished processing queue.'); isSequentialQueueRunning = false; if (NetworkStore.isOffline() || PersistedRequests.getAll().length === 0) { resolveIsReadyPromise?.(); @@ -144,6 +164,7 @@ function flush() { */ function unpause() { if (!isQueuePaused) { + Log.info('[SequentialQueue] Unable to unpause queue. We are already processing.'); return; } diff --git a/src/libs/Network/enhanceParameters.ts b/src/libs/Network/enhanceParameters.ts index 5c4590baded3..712d76db927c 100644 --- a/src/libs/Network/enhanceParameters.ts +++ b/src/libs/Network/enhanceParameters.ts @@ -17,7 +17,7 @@ export default function enhanceParameters(command: string, parameters: Record { - const isInternetReachable = !!state.isInternetReachable; - setOfflineStatus(isInternetReachable); + const isInternetReachable = (state.isInternetReachable ?? false) === false; + setOfflineStatus(isInternetReachable, 'NetInfo checked if the internet is reachable'); Log.info( `[NetworkStatus] The force-offline mode was turned off. Getting the device network status from NetInfo. Network state: ${JSON.stringify( state, @@ -122,20 +122,20 @@ function subscribeToBackendAndInternetReachability(): () => void { }) .then((isBackendReachable: boolean) => { if (isBackendReachable) { - NetworkActions.setIsBackendReachable(true); + NetworkActions.setIsBackendReachable(true, 'successfully completed API request'); return; } + NetworkActions.setIsBackendReachable(false, 'request succeeded, but internet reachability test failed'); checkInternetReachability().then((isInternetReachable: boolean) => { - setOfflineStatus(!isInternetReachable); + setOfflineStatus(!isInternetReachable, 'checkInternetReachability was called after api/ping returned a non-200 jsonCode'); setNetWorkStatus(isInternetReachable); - NetworkActions.setIsBackendReachable(false); }); }) .catch(() => { + NetworkActions.setIsBackendReachable(false, 'request failed and internet reachability test failed'); checkInternetReachability().then((isInternetReachable: boolean) => { - setOfflineStatus(!isInternetReachable); + setOfflineStatus(!isInternetReachable, 'checkInternetReachability was called after api/ping request failed'); setNetWorkStatus(isInternetReachable); - NetworkActions.setIsBackendReachable(false); }); }); }, CONST.NETWORK.BACKEND_CHECK_INTERVAL_MS); @@ -163,7 +163,7 @@ function subscribeToNetworkStatus(): () => void { Log.info('[NetworkConnection] Not setting offline status because shouldForceOffline = true'); return; } - setOfflineStatus(state.isInternetReachable === false); + setOfflineStatus(state.isInternetReachable === false, 'NetInfo received a state change event'); Log.info(`[NetworkStatus] NetInfo.addEventListener event coming, setting "offlineStatus" to ${!!state.isInternetReachable} with network state: ${JSON.stringify(state)}`); setNetWorkStatus(state.isInternetReachable); }); diff --git a/src/libs/OptionsListUtils.ts b/src/libs/OptionsListUtils.ts index 9840e29ff347..9b30764b065d 100644 --- a/src/libs/OptionsListUtils.ts +++ b/src/libs/OptionsListUtils.ts @@ -15,6 +15,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type { Beta, Login, + OnyxInputOrEntry, PersonalDetails, PersonalDetailsList, Policy, @@ -290,7 +291,7 @@ Onyx.connect({ // If the report is a one-transaction report and has , we need to return the combined reportActions so that the LHN can display modifications // to the transaction thread or the report itself - const transactionThreadReportID = ReportActionUtils.getOneTransactionThreadReportID(reportID, actions[reportActions[0]], true); + const transactionThreadReportID = ReportActionUtils.getOneTransactionThreadReportID(reportID, actions[reportActions[0]]); if (transactionThreadReportID) { const transactionThreadReportActionsArray = Object.values(actions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReportID}`] ?? {}); sortedReportActions = ReportActionUtils.getCombinedReportActions(reportActionsArray, transactionThreadReportActionsArray, reportID); @@ -362,7 +363,7 @@ function getAvatarsForAccountIDs(accountIDs: number[], personalDetails: OnyxEntr * Returns the personal details for an array of accountIDs * @returns keys of the object are emails, values are PersonalDetails objects. */ -function getPersonalDetailsForAccountIDs(accountIDs: number[] | undefined, personalDetails: OnyxEntry): PersonalDetailsList { +function getPersonalDetailsForAccountIDs(accountIDs: number[] | undefined, personalDetails: OnyxInputOrEntry): PersonalDetailsList { const personalDetailsForAccountIDs: PersonalDetailsList = {}; if (!personalDetails) { return personalDetailsForAccountIDs; @@ -372,7 +373,7 @@ function getPersonalDetailsForAccountIDs(accountIDs: number[] | undefined, perso if (!cleanAccountID) { return; } - let personalDetail: OnyxEntry = personalDetails[accountID]; + let personalDetail: OnyxEntry = personalDetails[accountID] ?? undefined; if (!personalDetail) { personalDetail = {} as PersonalDetails; } @@ -479,7 +480,7 @@ function uniqFast(items: string[]): string[] { * Array.prototype.push.apply is faster than using the spread operator. */ function getSearchText( - report: OnyxEntry, + report: OnyxInputOrEntry, reportName: string, personalDetailList: Array>, isChatRoomOrPolicyExpenseChat: boolean, @@ -529,7 +530,7 @@ function getAllReportErrors(report: OnyxEntry, reportActions: OnyxEntry< return Object.assign(prevReportActionErrors, action.errors); }, {}); const parentReportAction: OnyxEntry = - !report?.parentReportID || !report?.parentReportActionID ? null : allReportActions?.[report.parentReportID ?? '']?.[report.parentReportActionID ?? ''] ?? null; + !report?.parentReportID || !report?.parentReportActionID ? undefined : allReportActions?.[report.parentReportID ?? '']?.[report.parentReportActionID ?? '']; if (ReportActionUtils.wasActionTakenByCurrentUser(parentReportAction) && ReportActionUtils.isTransactionThread(parentReportAction)) { const transactionID = parentReportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.IOU ? parentReportAction?.originalMessage?.IOUTransactionID : null; @@ -601,6 +602,26 @@ function getAlternateText(option: ReportUtils.OptionData, {showChatPreviewLine = : LocalePhoneNumber.formatPhoneNumber(option.participantsList && option.participantsList.length > 0 ? option.participantsList[0].login ?? '' : ''); } +function isSearchStringMatchUserDetails(personalDetail: PersonalDetails, searchValue: string) { + let memberDetails = ''; + if (personalDetail.login) { + memberDetails += ` ${personalDetail.login}`; + } + if (personalDetail.firstName) { + memberDetails += ` ${personalDetail.firstName}`; + } + if (personalDetail.lastName) { + memberDetails += ` ${personalDetail.lastName}`; + } + if (personalDetail.displayName) { + memberDetails += ` ${PersonalDetailsUtils.getDisplayNameOrDefault(personalDetail)}`; + } + if (personalDetail.phoneNumber) { + memberDetails += ` ${personalDetail.phoneNumber}`; + } + return isSearchStringMatch(searchValue.trim(), memberDetails.toLowerCase()); +} + /** * Get the last message text from the report directly or from other sources for special cases. */ @@ -679,8 +700,8 @@ function getLastMessageTextForReport(report: OnyxEntry, lastActorDetails */ function createOption( accountIDs: number[], - personalDetails: OnyxEntry, - report: OnyxEntry, + personalDetails: OnyxInputOrEntry, + report: OnyxInputOrEntry, reportActions: ReportActions, config?: PreviewConfig, ): ReportUtils.OptionData { @@ -829,7 +850,7 @@ function getReportOption(participant: Participant): ReportUtils.OptionData { const option = createOption( visibleParticipantAccountIDs, allPersonalDetails ?? {}, - !isEmptyObject(report) ? report : null, + !isEmptyObject(report) ? report : undefined, {}, { showChatPreviewLine: false, @@ -1933,7 +1954,8 @@ function getOptions( const isCurrentUserOwnedPolicyExpenseChatThatCouldShow = reportOption.isPolicyExpenseChat && reportOption.ownerAccountID === currentUserAccountID && includeOwnedWorkspaceChats && !reportOption.isArchivedRoom; - const shouldShowInvoiceRoom = includeInvoiceRooms && ReportUtils.isInvoiceRoom(reportOption.item) && ReportUtils.isPolicyAdmin(reportOption.policyID ?? '', policies); + const shouldShowInvoiceRoom = + includeInvoiceRooms && ReportUtils.isInvoiceRoom(reportOption.item) && ReportUtils.isPolicyAdmin(reportOption.policyID ?? '', policies) && !reportOption.isArchivedRoom; /** Exclude the report option if it doesn't meet any of the following conditions: @@ -2470,6 +2492,7 @@ export { getPersonalDetailsForAccountIDs, getIOUConfirmationOptionsFromPayeePersonalDetail, getSearchText, + isSearchStringMatchUserDetails, getAllReportErrors, getPolicyExpenseReportOption, getParticipantsOption, diff --git a/src/libs/Performance.tsx b/src/libs/Performance.tsx index 9302b538a621..f5e84b8f6ef3 100644 --- a/src/libs/Performance.tsx +++ b/src/libs/Performance.tsx @@ -3,7 +3,7 @@ import isObject from 'lodash/isObject'; import lodashTransform from 'lodash/transform'; import React, {forwardRef, Profiler} from 'react'; import {Alert, InteractionManager} from 'react-native'; -import type {PerformanceEntry, PerformanceMark, PerformanceMeasure, Performance as RNPerformance} from 'react-native-performance'; +import type {PerformanceEntry, PerformanceMark, PerformanceMeasure, ReactNativePerformance, Performance as RNPerformance} from 'react-native-performance'; import type {PerformanceObserverEntryList} from 'react-native-performance/lib/typescript/performance-observer'; import CONST from '@src/CONST'; import isE2ETestSession from './E2E/isE2ETestSession'; @@ -86,7 +86,7 @@ const Performance: PerformanceModule = { }; if (Metrics.canCapturePerformanceMetrics()) { - const perfModule = require('react-native-performance'); + const perfModule: ReactNativePerformance = require('react-native-performance'); perfModule.setResourceLoggingEnabled(true); rnPerformance = perfModule.default; diff --git a/src/libs/PersonalDetailsUtils.ts b/src/libs/PersonalDetailsUtils.ts index 8cea00a28d94..9a35dfc41b72 100644 --- a/src/libs/PersonalDetailsUtils.ts +++ b/src/libs/PersonalDetailsUtils.ts @@ -4,7 +4,7 @@ import Onyx from 'react-native-onyx'; import type {CurrentUserPersonalDetails} from '@components/withCurrentUserPersonalDetails'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {PersonalDetails, PersonalDetailsList, PrivatePersonalDetails} from '@src/types/onyx'; +import type {OnyxInputOrEntry, PersonalDetails, PersonalDetailsList, PrivatePersonalDetails} from '@src/types/onyx'; import type {OnyxData} from '@src/types/onyx/Request'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import * as LocalePhoneNumber from './LocalePhoneNumber'; @@ -250,7 +250,7 @@ function getEffectiveDisplayName(personalDetail?: PersonalDetails): string | und /** * Creates a new displayName for a user based on passed personal details or login. */ -function createDisplayName(login: string, passedPersonalDetails: Pick | OnyxEntry): string { +function createDisplayName(login: string, passedPersonalDetails: Pick | OnyxInputOrEntry): string { // If we have a number like +15857527441@expensify.sms then let's remove @expensify.sms and format it // so that the option looks cleaner in our UI. const userLogin = LocalePhoneNumber.formatPhoneNumber(login); diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts index 7678de592a6f..6d91de95bb91 100644 --- a/src/libs/PolicyUtils.ts +++ b/src/libs/PolicyUtils.ts @@ -6,7 +6,7 @@ import type {SelectorType} from '@components/SelectionScreen'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import type {Policy, PolicyCategories, PolicyEmployeeList, PolicyTagList, PolicyTags, TaxRate} from '@src/types/onyx'; +import type {OnyxInputOrEntry, Policy, PolicyCategories, PolicyEmployeeList, PolicyTagList, PolicyTags, TaxRate} from '@src/types/onyx'; import type {PolicyFeatureName, Rate, Tenant} from '@src/types/onyx/Policy'; import type PolicyEmployee from '@src/types/onyx/PolicyEmployee'; import type {EmptyObject} from '@src/types/utils/EmptyObject'; @@ -34,9 +34,9 @@ Onyx.connect({ * Filter out the active policies, which will exclude policies with pending deletion * These are policies that we can use to create reports with in NewDot. */ -function getActivePolicies(policies: OnyxCollection): Policy[] { +function getActivePolicies(policies: OnyxCollection | null): Policy[] { return Object.values(policies ?? {}).filter( - (policy): policy is Policy => policy !== null && policy && policy.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && !!policy.name && !!policy.id, + (policy): policy is Policy => !!policy && policy.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && !!policy.name && !!policy.id, ); } @@ -137,7 +137,7 @@ function getPolicyBrickRoadIndicatorStatus(policy: OnyxEntry): ValueOf, isOffline: boolean): boolean { return ( !!policy && - (policy?.isPolicyExpenseChatEnabled || !!policy?.isJoinRequestPending) && + (policy?.type !== CONST.POLICY.TYPE.PERSONAL || !!policy?.isJoinRequestPending) && (isOffline || policy?.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || Object.keys(policy.errors ?? {}).length > 0) ); } @@ -150,7 +150,7 @@ function isExpensifyTeam(email: string | undefined): boolean { /** * Checks if the current user is an admin of the policy. */ -const isPolicyAdmin = (policy: OnyxEntry | EmptyObject, currentUserLogin?: string): boolean => +const isPolicyAdmin = (policy: OnyxInputOrEntry | EmptyObject, currentUserLogin?: string): boolean => (policy?.role ?? (currentUserLogin && policy?.employeeList?.[currentUserLogin]?.role)) === CONST.POLICY.ROLE.ADMIN; /** @@ -163,7 +163,7 @@ const isPolicyEmployee = (policyID: string, policies: OnyxCollection): b /** * Checks if the current user is an owner (creator) of the policy. */ -const isPolicyOwner = (policy: OnyxEntry | EmptyObject, currentUserAccountID: number): boolean => policy?.ownerAccountID === currentUserAccountID; +const isPolicyOwner = (policy: OnyxInputOrEntry | EmptyObject, currentUserAccountID: number): boolean => policy?.ownerAccountID === currentUserAccountID; /** * Create an object mapping member emails to their accountIDs. Filter for members without errors if includeMemberWithErrors is false, and get the login email from the personalDetail object using the accountID. @@ -305,14 +305,14 @@ function isTaxTrackingEnabled(isPolicyExpenseChat: boolean, policy: OnyxEntry | EmptyObject): boolean { +function isInstantSubmitEnabled(policy: OnyxInputOrEntry | EmptyObject): boolean { return policy?.type === CONST.POLICY.TYPE.FREE || (policy?.autoReporting === true && policy?.autoReportingFrequency === CONST.POLICY.AUTO_REPORTING_FREQUENCIES.INSTANT); } /** * Checks if policy's approval mode is "optional", a.k.a. "Submit & Close" */ -function isSubmitAndClose(policy: OnyxEntry | EmptyObject): boolean { +function isSubmitAndClose(policy: OnyxInputOrEntry | EmptyObject): boolean { return policy?.approvalMode === CONST.POLICY.APPROVAL_MODE.OPTIONAL; } @@ -414,16 +414,23 @@ function getPolicy(policyID: string | undefined): Policy | EmptyObject { } /** Return active policies where current user is an admin */ -function getActiveAdminWorkspaces(policies: OnyxCollection): Policy[] { +function getActiveAdminWorkspaces(policies: OnyxCollection | null): Policy[] { const activePolicies = getActivePolicies(policies); return activePolicies.filter((policy) => shouldShowPolicy(policy, NetworkStore.isOffline()) && isPolicyAdmin(policy)); } /** Whether the user can send invoice */ -function canSendInvoice(policies: OnyxCollection): boolean { +function canSendInvoice(policies: OnyxCollection | null): boolean { return getActiveAdminWorkspaces(policies).length > 0; } +function hasDependentTags(policy: OnyxEntry, policyTagList: OnyxEntry) { + if (!policy?.hasMultipleTagLists) { + return false; + } + return Object.values(policyTagList ?? {}).some((tagList) => Object.values(tagList.tags).some((tag) => !!tag.rules?.parentTagsFilter || !!tag.parentTagsFilter)); +} + /** Get the Xero organizations connected to the policy */ function getXeroTenants(policy: Policy | undefined): Tenant[] { // Due to the way optional chain is being handled in this useMemo we are forced to use this approach to properly handle undefined values @@ -516,6 +523,7 @@ export { shouldShowPolicy, getActiveAdminWorkspaces, canSendInvoice, + hasDependentTags, getXeroTenants, findCurrentXeroOrganization, getCurrentXeroOrganizationName, diff --git a/src/libs/Pusher/pusher.ts b/src/libs/Pusher/pusher.ts index 6fe3eddb85d9..35864d1b6f2e 100644 --- a/src/libs/Pusher/pusher.ts +++ b/src/libs/Pusher/pusher.ts @@ -170,7 +170,7 @@ function bindEventToChannel(channel: Channel let data: EventData; try { - data = isObject(eventData) ? eventData : JSON.parse(eventData); + data = isObject(eventData) ? eventData : JSON.parse(eventData as string); } catch (err) { Log.alert('[Pusher] Unable to parse single JSON event data from Pusher', {error: err, eventData}); return; diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts index ac0ee9e9025e..aa7caf3353b9 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -1,12 +1,13 @@ import {fastMerge} from 'expensify-common'; import _ from 'lodash'; import lodashFindLast from 'lodash/findLast'; -import type {OnyxCollection, OnyxEntry, OnyxUpdate} from 'react-native-onyx'; +import type {OnyxCollection, OnyxCollectionInputValue, OnyxEntry, OnyxUpdate} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; +import type {OnyxInputOrEntry} from '@src/types/onyx'; import type { ActionName, ChangeLog, @@ -101,11 +102,11 @@ Onyx.connect({ let environmentURL: string; Environment.getEnvironmentURL().then((url: string) => (environmentURL = url)); -function isCreatedAction(reportAction: OnyxEntry): boolean { +function isCreatedAction(reportAction: OnyxInputOrEntry): boolean { return reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED; } -function isDeletedAction(reportAction: OnyxEntry): boolean { +function isDeletedAction(reportAction: OnyxInputOrEntry): boolean { const message = reportAction?.message ?? []; // A legacy deleted comment has either an empty array or an object with html field with empty string as value @@ -114,34 +115,34 @@ function isDeletedAction(reportAction: OnyxEntry): boolean { +function isDeletedParentAction(reportAction: OnyxInputOrEntry): boolean { return (reportAction?.message?.[0]?.isDeletedParentAction ?? false) && (reportAction?.childVisibleActionCount ?? 0) > 0; } -function isReversedTransaction(reportAction: OnyxEntry) { +function isReversedTransaction(reportAction: OnyxInputOrEntry) { return (reportAction?.message?.[0]?.isReversedTransaction ?? false) && ((reportAction as ReportAction)?.childVisibleActionCount ?? 0) > 0; } -function isPendingRemove(reportAction: OnyxEntry | EmptyObject): boolean { +function isPendingRemove(reportAction: OnyxInputOrEntry | EmptyObject): boolean { if (isEmptyObject(reportAction)) { return false; } return reportAction?.message?.[0]?.moderationDecision?.decision === CONST.MODERATION.MODERATOR_DECISION_PENDING_REMOVE; } -function isMoneyRequestAction(reportAction: OnyxEntry): reportAction is ReportAction & OriginalMessageIOU { +function isMoneyRequestAction(reportAction: OnyxInputOrEntry): reportAction is ReportAction & OriginalMessageIOU { return reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.IOU; } -function isReportPreviewAction(reportAction: OnyxEntry): boolean { +function isReportPreviewAction(reportAction: OnyxInputOrEntry): boolean { return reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW; } -function isReportActionSubmitted(reportAction: OnyxEntry): boolean { +function isReportActionSubmitted(reportAction: OnyxInputOrEntry): boolean { return reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.SUBMITTED; } -function isModifiedExpenseAction(reportAction: OnyxEntry | ReportAction | Record): boolean { +function isModifiedExpenseAction(reportAction: OnyxInputOrEntry | ReportAction | Record): boolean { return reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.MODIFIED_EXPENSE; } @@ -149,7 +150,7 @@ function isModifiedExpenseAction(reportAction: OnyxEntry | ReportA * We are in the process of deprecating reportAction.originalMessage and will be setting the db version of "message" to reportAction.message in the future see: https://github.com/Expensify/App/issues/39797 * In the interim, we must check to see if we have an object or array for the reportAction.message, if we have an array we will use the originalMessage as this means we have not yet migrated. */ -function getWhisperedTo(reportAction: OnyxEntry | EmptyObject): number[] { +function getWhisperedTo(reportAction: OnyxInputOrEntry | EmptyObject): number[] { const originalMessage = reportAction?.originalMessage; const message = reportAction?.message; @@ -164,25 +165,25 @@ function getWhisperedTo(reportAction: OnyxEntry | EmptyObject): nu return []; } -function isWhisperAction(reportAction: OnyxEntry | EmptyObject): boolean { +function isWhisperAction(reportAction: OnyxInputOrEntry | EmptyObject): boolean { return getWhisperedTo(reportAction).length > 0; } /** * Checks whether the report action is a whisper targeting someone other than the current user. */ -function isWhisperActionTargetedToOthers(reportAction: OnyxEntry): boolean { +function isWhisperActionTargetedToOthers(reportAction: OnyxInputOrEntry): boolean { if (!isWhisperAction(reportAction)) { return false; } return !getWhisperedTo(reportAction).includes(currentUserAccountID ?? 0); } -function isReimbursementQueuedAction(reportAction: OnyxEntry) { +function isReimbursementQueuedAction(reportAction: OnyxInputOrEntry) { return reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.REIMBURSEMENT_QUEUED; } -function isMemberChangeAction(reportAction: OnyxEntry) { +function isMemberChangeAction(reportAction: OnyxInputOrEntry) { return ( reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.ROOM_CHANGE_LOG.INVITE_TO_ROOM || reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.ROOM_CHANGE_LOG.REMOVE_FROM_ROOM || @@ -217,7 +218,7 @@ function isThreadParentMessage(reportAction: OnyxEntry, reportID: * * @deprecated Use Onyx.connect() or withOnyx() instead */ -function getParentReportAction(report: OnyxEntry | EmptyObject): ReportAction | EmptyObject { +function getParentReportAction(report: OnyxInputOrEntry | EmptyObject): ReportAction | EmptyObject { if (!report?.parentReportID || !report.parentReportActionID) { return {}; } @@ -239,7 +240,7 @@ function isSentMoneyReportAction(reportAction: OnyxEntry | EmptyObject): boolean { +function isTransactionThread(parentReportAction: OnyxInputOrEntry | EmptyObject): boolean { return ( parentReportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.IOU && (parentReportAction.originalMessage.type === CONST.IOU.REPORT_ACTION_TYPE.CREATE || @@ -420,9 +421,9 @@ function extractLinksFromMessageHtml(reportAction: OnyxEntry): str * @param reportActions - all actions * @param actionIndex - index of the action */ -function findPreviousAction(reportActions: ReportAction[] | null, actionIndex: number): OnyxEntry { +function findPreviousAction(reportActions: ReportAction[] | undefined, actionIndex: number): OnyxEntry { if (!reportActions) { - return null; + return undefined; } for (let i = actionIndex + 1; i < reportActions.length; i++) { @@ -433,7 +434,7 @@ function findPreviousAction(reportActions: ReportAction[] | null, actionIndex: n } } - return null; + return undefined; } /** @@ -442,7 +443,7 @@ function findPreviousAction(reportActions: ReportAction[] | null, actionIndex: n * * @param actionIndex - index of the comment item in state to check */ -function isConsecutiveActionMadeByPreviousActor(reportActions: ReportAction[] | null, actionIndex: number): boolean { +function isConsecutiveActionMadeByPreviousActor(reportActions: ReportAction[] | undefined, actionIndex: number): boolean { const previousAction = findPreviousAction(reportActions, actionIndex); const currentAction = reportActions?.[actionIndex]; @@ -581,7 +582,7 @@ function shouldHideNewMarker(reportAction: OnyxEntry): boolean { * Checks whether an action is actionable track expense. * */ -function isActionableTrackExpense(reportAction: OnyxEntry): reportAction is ReportActionBase & OriginalMessageActionableTrackedExpenseWhisper { +function isActionableTrackExpense(reportAction: OnyxInputOrEntry): reportAction is ReportActionBase & OriginalMessageActionableTrackedExpenseWhisper { return reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.ACTIONABLE_TRACK_EXPENSE_WHISPER; } @@ -598,7 +599,7 @@ function isResolvedActionTrackExpense(reportAction: OnyxEntry): bo * Checks if a reportAction is fit for display as report last action, meaning that * it satisfies shouldReportActionBeVisible, it's not whisper action and not deleted. */ -function shouldReportActionBeVisibleAsLastAction(reportAction: OnyxEntry): boolean { +function shouldReportActionBeVisibleAsLastAction(reportAction: OnyxInputOrEntry): boolean { if (!reportAction) { return false; } @@ -639,17 +640,21 @@ function replaceBaseURLInPolicyChangeLogAction(reportAction: ReportAction): Repo return updatedReportAction; } -function getLastVisibleAction(reportID: string, actionsToMerge: OnyxCollection = {}): OnyxEntry { - const reportActions = Object.values(fastMerge(allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`] ?? {}, actionsToMerge ?? {}, true)); +function getLastVisibleAction(reportID: string, actionsToMerge: OnyxCollection | OnyxCollectionInputValue = {}): OnyxEntry { + const reportActions = Object.values(fastMerge(allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`] ?? {}, actionsToMerge ?? {}, true)) as Array; const visibleReportActions = Object.values(reportActions ?? {}).filter((action): action is ReportAction => shouldReportActionBeVisibleAsLastAction(action)); const sortedReportActions = getSortedReportActions(visibleReportActions, true); if (sortedReportActions.length === 0) { - return null; + return undefined; } return sortedReportActions[0]; } -function getLastVisibleMessage(reportID: string, actionsToMerge: OnyxCollection = {}, reportAction: OnyxEntry | undefined = undefined): LastVisibleMessage { +function getLastVisibleMessage( + reportID: string, + actionsToMerge: OnyxCollection | OnyxCollectionInputValue = {}, + reportAction: OnyxInputOrEntry | undefined = undefined, +): LastVisibleMessage { const lastVisibleAction = reportAction ?? getLastVisibleAction(reportID, actionsToMerge); const message = lastVisibleAction?.message?.[0]; @@ -679,7 +684,7 @@ function getLastVisibleMessage(reportID: string, actionsToMerge: OnyxCollection< /** * A helper method to filter out report actions keyed by sequenceNumbers. */ -function filterOutDeprecatedReportActions(reportActions: ReportActions | null): ReportAction[] { +function filterOutDeprecatedReportActions(reportActions: OnyxEntry): ReportAction[] { return Object.entries(reportActions ?? {}) .filter(([key, reportAction]) => !isReportActionDeprecated(reportAction, key)) .map((entry) => entry[1]); @@ -691,7 +696,7 @@ function filterOutDeprecatedReportActions(reportActions: ReportActions | null): * to ensure they will always be displayed in the same order (in case multiple actions have the same timestamp). * This is all handled with getSortedReportActions() which is used by several other methods to keep the code DRY. */ -function getSortedReportActionsForDisplay(reportActions: ReportActions | null | ReportAction[], shouldIncludeInvisibleActions = false): ReportAction[] { +function getSortedReportActionsForDisplay(reportActions: OnyxEntry | ReportAction[], shouldIncludeInvisibleActions = false): ReportAction[] { let filteredReportActions: ReportAction[] = []; if (!reportActions) { return []; @@ -715,15 +720,15 @@ function getSortedReportActionsForDisplay(reportActions: ReportActions | null | * Additionally, archived #admins and #announce do not have the closed report action so we will return null if none is found. * */ -function getLastClosedReportAction(reportActions: ReportActions | null): OnyxEntry { +function getLastClosedReportAction(reportActions: OnyxEntry): OnyxEntry { // If closed report action is not present, return early if (!Object.values(reportActions ?? {}).some((action) => action.actionName === CONST.REPORT.ACTIONS.TYPE.CLOSED)) { - return null; + return undefined; } const filteredReportActions = filterOutDeprecatedReportActions(reportActions); const sortedReportActions = getSortedReportActions(filteredReportActions); - return lodashFindLast(sortedReportActions, (action) => action.actionName === CONST.REPORT.ACTIONS.TYPE.CLOSED) ?? null; + return lodashFindLast(sortedReportActions, (action) => action.actionName === CONST.REPORT.ACTIONS.TYPE.CLOSED); } /** @@ -745,7 +750,7 @@ function getFirstVisibleReportActionID(sortedReportActions: ReportAction[] = [], /** * @returns The latest report action in the `onyxData` or `null` if one couldn't be found */ -function getLatestReportActionFromOnyxData(onyxData: OnyxUpdate[] | null): OnyxEntry { +function getLatestReportActionFromOnyxData(onyxData: OnyxUpdate[] | null): NonNullable> | null { const reportActionUpdate = onyxData?.find((onyxUpdate) => onyxUpdate.key.startsWith(ONYXKEYS.COLLECTION.REPORT_ACTIONS)); if (!reportActionUpdate) { @@ -768,8 +773,8 @@ function getLinkedTransactionID(reportActionOrID: string | OnyxEntry { - return allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`]?.[reportActionID] ?? null; +function getReportAction(reportID: string, reportActionID: string): ReportAction | undefined { + return allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`]?.[reportActionID]; } function getMostRecentReportActionLastModified(): string { @@ -815,10 +820,8 @@ function getMostRecentReportActionLastModified(): string { * @returns The report preview action or `null` if one couldn't be found */ function getReportPreviewAction(chatReportID: string, iouReportID: string): OnyxEntry { - return ( - Object.values(allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReportID}`] ?? {}).find( - (reportAction) => reportAction && reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW && reportAction.originalMessage.linkedReportID === iouReportID, - ) ?? null + return Object.values(allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReportID}`] ?? {}).find( + (reportAction) => reportAction && reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW && reportAction.originalMessage.linkedReportID === iouReportID, ); } @@ -836,7 +839,7 @@ function isCreatedTaskReportAction(reportAction: OnyxEntry): boole /** * A helper method to identify if the message is deleted or not. */ -function isMessageDeleted(reportAction: OnyxEntry): boolean { +function isMessageDeleted(reportAction: OnyxInputOrEntry): boolean { return reportAction?.message?.[0]?.isDeletedParentAction ?? false; } @@ -847,7 +850,7 @@ function getNumberOfMoneyRequests(reportPreviewAction: OnyxEntry): return reportPreviewAction?.childMoneyRequestCount ?? 0; } -function isSplitBillAction(reportAction: OnyxEntry): boolean { +function isSplitBillAction(reportAction: OnyxInputOrEntry): boolean { return reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.IOU && reportAction.originalMessage.type === CONST.IOU.REPORT_ACTION_TYPE.SPLIT; } @@ -855,7 +858,7 @@ function isTrackExpenseAction(reportAction: OnyxEntry): boolean { +function isPayAction(reportAction: OnyxInputOrEntry): boolean { return reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.IOU && (reportAction.originalMessage as IOUMessage).type === CONST.IOU.REPORT_ACTION_TYPE.PAY; } @@ -873,23 +876,16 @@ function isTaskAction(reportAction: OnyxEntry): boolean { * 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[], - skipReportTypeCheck: boolean | undefined = undefined, - isOffline: boolean | undefined = undefined, -): string | null { - if (!skipReportTypeCheck) { - // If the report is not an IOU, Expense report, or Invoice, it shouldn't be treated as one-transaction report. - const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; - if (report?.type !== CONST.REPORT.TYPE.IOU && report?.type !== CONST.REPORT.TYPE.EXPENSE && report?.type !== CONST.REPORT.TYPE.INVOICE) { - return null; - } +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. + const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; + if (report?.type !== CONST.REPORT.TYPE.IOU && report?.type !== CONST.REPORT.TYPE.EXPENSE && report?.type !== CONST.REPORT.TYPE.INVOICE) { + return; } const reportActionsArray = Object.values(reportActions ?? {}); if (!reportActionsArray.length) { - return null; + return; } // Get all IOU report actions for the report. @@ -917,17 +913,17 @@ function getOneTransactionThreadReportID( // 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) { - return null; + return; } // If there's only one IOU request action associated with the report but it's been deleted, then we don't consider this a oneTransaction report // and want to display it using the standard view if (((iouRequestActions[0] as OriginalMessageIOU).originalMessage?.deleted ?? '') !== '') { - return null; + return; } // Ensure we have a childReportID associated with the IOU report action - return iouRequestActions[0].childReportID ?? null; + return iouRequestActions[0].childReportID; } /** @@ -951,7 +947,7 @@ function getAllReportActions(reportID: string): ReportActions { * Check whether a report action is an attachment (a file, such as an image or a zip). * */ -function isReportActionAttachment(reportAction: OnyxEntry): boolean { +function isReportActionAttachment(reportAction: OnyxInputOrEntry): boolean { const message = reportAction?.message?.[0]; if (reportAction && ('isAttachment' in reportAction || 'attachmentInfo' in reportAction)) { @@ -1239,7 +1235,7 @@ function isLinkedTransactionHeld(reportActionID: string, reportID: string): bool /** * Check if the current user is the requestor of the action */ -function wasActionTakenByCurrentUser(reportAction: OnyxEntry): boolean { +function wasActionTakenByCurrentUser(reportAction: OnyxInputOrEntry): boolean { return currentUserAccountID === reportAction?.actorAccountID; } diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index d68d308feedd..914c653b6f91 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -7,7 +7,7 @@ import lodashIntersection from 'lodash/intersection'; import lodashIsEqual from 'lodash/isEqual'; import type {OnyxCollection, OnyxEntry, OnyxUpdate} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; -import type {ValueOf} from 'type-fest'; +import type {TupleToUnion, ValueOf} from 'type-fest'; import type {FileObject} from '@components/AttachmentModal'; import {FallbackAvatar} from '@components/Icon/Expensicons'; import * as defaultGroupAvatars from '@components/Icon/GroupDefaultAvatars'; @@ -21,6 +21,7 @@ import type {Route} from '@src/ROUTES'; import ROUTES from '@src/ROUTES'; import type { Beta, + OnyxInputOrEntry, PersonalDetails, PersonalDetailsList, Policy, @@ -490,13 +491,13 @@ Onyx.connect({ }, }); -let allPersonalDetails: OnyxCollection; +let allPersonalDetails: OnyxEntry; let allPersonalDetailLogins: string[]; let currentUserPersonalDetails: OnyxEntry; Onyx.connect({ key: ONYXKEYS.PERSONAL_DETAILS_LIST, callback: (value) => { - currentUserPersonalDetails = value?.[currentUserAccountID ?? -1] ?? null; + currentUserPersonalDetails = value?.[currentUserAccountID ?? -1] ?? undefined; allPersonalDetails = value ?? {}; allPersonalDetailLogins = Object.values(allPersonalDetails).map((personalDetail) => personalDetail?.login ?? ''); }, @@ -578,7 +579,7 @@ function getCurrentUserDisplayNameOrEmail(): string | undefined { return currentUserPersonalDetails?.displayName ?? currentUserEmail; } -function getChatType(report: OnyxEntry | Participant | EmptyObject): ValueOf | undefined { +function getChatType(report: OnyxInputOrEntry | Participant | EmptyObject): ValueOf | undefined { return report?.chatType; } @@ -587,13 +588,13 @@ function getChatType(report: OnyxEntry | Participant | EmptyObject): Val */ function getReport(reportID: string | undefined): OnyxEntry { if (!allReports && !allReportsDraft) { - return null; + return undefined; } const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; const draftReport = allReportsDraft?.[`${ONYXKEYS.COLLECTION.REPORT_DRAFT}${reportID}`]; - return report ?? draftReport ?? null; + return report ?? draftReport; } /** @@ -632,7 +633,7 @@ function getRootParentReport(report: OnyxEntry | undefined | EmptyObject const parentReport = getReport(report?.parentReportID); // Runs recursion to iterate a parent report - return getRootParentReport(!isEmptyObject(parentReport) ? parentReport : null); + return getRootParentReport(!isEmptyObject(parentReport) ? parentReport : undefined); } /** @@ -649,14 +650,14 @@ function getPolicy(policyID: string | undefined): Policy | EmptyObject { * Get the policy type from a given report * @param policies must have Onyxkey prefix (i.e 'policy_') for keys */ -function getPolicyType(report: OnyxEntry, policies: OnyxCollection): string { +function getPolicyType(report: OnyxInputOrEntry, policies: OnyxCollection): string { return policies?.[`${ONYXKEYS.COLLECTION.POLICY}${report?.policyID}`]?.type ?? ''; } /** * Get the policy name from a given report */ -function getPolicyName(report: OnyxEntry | undefined | EmptyObject, returnEmptyIfNotFound = false, policy: OnyxEntry | undefined = undefined): string { +function getPolicyName(report: OnyxInputOrEntry | undefined | EmptyObject, returnEmptyIfNotFound = false, policy?: OnyxInputOrEntry): string { const noPolicyFound = returnEmptyIfNotFound ? '' : Localize.translateLocal('workspace.common.unavailable'); if (isEmptyObject(report)) { return noPolicyFound; @@ -692,21 +693,21 @@ function isChatReport(report: OnyxEntry | EmptyObject): boolean { return report?.type === CONST.REPORT.TYPE.CHAT; } -function isInvoiceReport(report: OnyxEntry | EmptyObject): boolean { +function isInvoiceReport(report: OnyxInputOrEntry | EmptyObject): boolean { return report?.type === CONST.REPORT.TYPE.INVOICE; } /** * Checks if a report is an Expense report. */ -function isExpenseReport(report: OnyxEntry | EmptyObject): boolean { +function isExpenseReport(report: OnyxInputOrEntry | EmptyObject): boolean { return report?.type === CONST.REPORT.TYPE.EXPENSE; } /** * Checks if a report is an IOU report using report or reportID */ -function isIOUReport(reportOrID: OnyxEntry | string | EmptyObject): boolean { +function isIOUReport(reportOrID: OnyxInputOrEntry | string | EmptyObject): boolean { const report = typeof reportOrID === 'string' ? allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportOrID}`] ?? null : reportOrID; return report?.type === CONST.REPORT.TYPE.IOU; } @@ -720,7 +721,7 @@ function isIOUReportUsingReport(report: OnyxEntry | EmptyObject): report /** * Checks if a report is a task report. */ -function isTaskReport(report: OnyxEntry): boolean { +function isTaskReport(report: OnyxInputOrEntry): boolean { return report?.type === CONST.REPORT.TYPE.TASK; } @@ -731,7 +732,7 @@ function isTaskReport(report: OnyxEntry): boolean { * There's another situation where you don't have access to the parentReportAction (because it was created in a chat you don't have access to) * In this case, we have added the key to the report itself */ -function isCanceledTaskReport(report: OnyxEntry | EmptyObject = {}, parentReportAction: OnyxEntry | EmptyObject = {}): boolean { +function isCanceledTaskReport(report: OnyxInputOrEntry | EmptyObject = {}, parentReportAction: OnyxInputOrEntry | EmptyObject = {}): boolean { if (!isEmptyObject(parentReportAction) && (parentReportAction?.message?.[0]?.isDeletedParentAction ?? false)) { return true; } @@ -748,7 +749,7 @@ function isCanceledTaskReport(report: OnyxEntry | EmptyObject = {}, pare * * @param parentReportAction - The parent report action of the report (Used to check if the task has been canceled) */ -function isOpenTaskReport(report: OnyxEntry, parentReportAction: OnyxEntry | EmptyObject = {}): boolean { +function isOpenTaskReport(report: OnyxInputOrEntry, parentReportAction: OnyxInputOrEntry | EmptyObject = {}): boolean { return ( isTaskReport(report) && !isCanceledTaskReport(report, parentReportAction) && report?.stateNum === CONST.REPORT.STATE_NUM.OPEN && report?.statusNum === CONST.REPORT.STATUS_NUM.OPEN ); @@ -771,7 +772,7 @@ function isReportManager(report: OnyxEntry): boolean { /** * Checks if the supplied report has been approved */ -function isReportApproved(reportOrID: OnyxEntry | string | EmptyObject): boolean { +function isReportApproved(reportOrID: OnyxInputOrEntry | string | EmptyObject): boolean { const report = typeof reportOrID === 'string' ? allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportOrID}`] ?? null : reportOrID; return report?.stateNum === CONST.REPORT.STATE_NUM.APPROVED && report?.statusNum === CONST.REPORT.STATUS_NUM.APPROVED; } @@ -779,7 +780,7 @@ function isReportApproved(reportOrID: OnyxEntry | string | EmptyObject): /** * Checks if the supplied report is an expense report in Open state and status. */ -function isOpenExpenseReport(report: OnyxEntry | EmptyObject): boolean { +function isOpenExpenseReport(report: OnyxInputOrEntry | EmptyObject): boolean { return isExpenseReport(report) && report?.stateNum === CONST.REPORT.STATE_NUM.OPEN && report?.statusNum === CONST.REPORT.STATUS_NUM.OPEN; } @@ -879,7 +880,7 @@ function isUserCreatedPolicyRoom(report: OnyxEntry): boolean { /** * Whether the provided report is a Policy Expense chat. */ -function isPolicyExpenseChat(report: OnyxEntry | Participant | EmptyObject): boolean { +function isPolicyExpenseChat(report: OnyxInputOrEntry | Participant | EmptyObject): boolean { return getChatType(report) === CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT || (report?.isPolicyExpenseChat ?? false); } @@ -919,7 +920,7 @@ function isGroupPolicy(policyType: string): boolean { /** * Whether the provided report belongs to a Free, Collect or Control policy */ -function isReportInGroupPolicy(report: OnyxEntry, policy?: OnyxEntry): boolean { +function isReportInGroupPolicy(report: OnyxInputOrEntry, policy?: OnyxInputOrEntry): boolean { const policyType = policy?.type ?? getPolicyType(report, allPolicies); return isGroupPolicy(policyType); } @@ -1003,21 +1004,21 @@ function isWorkspaceTaskReport(report: OnyxEntry): boolean { if (!isTaskReport(report)) { return false; } - const parentReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${report?.parentReportID}`] ?? null; + const parentReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${report?.parentReportID}`]; return isPolicyExpenseChat(parentReport); } /** * Returns true if report has a parent */ -function isThread(report: OnyxEntry): boolean { +function isThread(report: OnyxInputOrEntry): boolean { return !!(report?.parentReportID && report?.parentReportActionID); } /** * Returns true if report is of type chat and has a parent and is therefore a Thread. */ -function isChatThread(report: OnyxEntry): boolean { +function isChatThread(report: OnyxInputOrEntry): boolean { return isThread(report) && report?.type === CONST.REPORT.TYPE.CHAT; } @@ -1025,7 +1026,7 @@ function isDM(report: OnyxEntry): boolean { return isChatReport(report) && !getChatType(report) && !isThread(report); } -function isSelfDM(report: OnyxEntry): boolean { +function isSelfDM(report: OnyxInputOrEntry): boolean { return getChatType(report) === CONST.REPORT.CHAT_TYPE.SELF_DM; } @@ -1040,7 +1041,7 @@ function isSystemChat(report: OnyxEntry): boolean { /** * Only returns true if this is our main 1:1 DM report with Concierge */ -function isConciergeChatReport(report: OnyxEntry): boolean { +function isConciergeChatReport(report: OnyxInputOrEntry): boolean { const participantAccountIDs = Object.keys(report?.participants ?? {}) .map(Number) .filter((accountID) => accountID !== currentUserAccountID); @@ -1195,7 +1196,7 @@ function findLastAccessedReport( return sortedReports[0]; } - return adminReport ?? sortedReports.find((report) => !isConciergeChatReport(report)) ?? null; + return adminReport ?? sortedReports.find((report) => !isConciergeChatReport(report)); } // If we only have two reports and one of them is the system chat, filter it out so we don't @@ -1205,7 +1206,7 @@ function findLastAccessedReport( sortedReports = sortedReports.filter((report) => !isSystemChat(report)) ?? []; } - return adminReport ?? sortedReports.at(-1) ?? null; + return adminReport ?? sortedReports.at(-1); } /** @@ -1225,7 +1226,7 @@ function isClosedExpenseReportWithNoExpenses(report: OnyxEntry): boolean /** * Whether the provided report is an archived room */ -function isArchivedRoom(report: OnyxEntry | EmptyObject, reportNameValuePairs?: OnyxEntry | EmptyObject): boolean { +function isArchivedRoom(report: OnyxInputOrEntry | EmptyObject, reportNameValuePairs?: OnyxInputOrEntry | EmptyObject): boolean { if (reportNameValuePairs) { return reportNameValuePairs.isArchived; } @@ -1343,7 +1344,7 @@ function isWorkspaceThread(report: OnyxEntry): boolean { /** * Returns true if reportAction is the first chat preview of a Thread */ -function isThreadFirstChat(reportAction: OnyxEntry, reportID: string): boolean { +function isThreadFirstChat(reportAction: OnyxInputOrEntry, reportID: string): boolean { return reportAction?.childReportID?.toString() === reportID; } @@ -1358,10 +1359,10 @@ function isChildReport(report: OnyxEntry): boolean { * An Expense Request is a thread where the parent report is an Expense Report and * the parentReportAction is a transaction. */ -function isExpenseRequest(report: OnyxEntry): boolean { +function isExpenseRequest(report: OnyxInputOrEntry): boolean { if (isThread(report)) { const parentReportAction = ReportActionsUtils.getParentReportAction(report); - const parentReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${report?.parentReportID}`] ?? null; + const parentReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${report?.parentReportID}`]; return isExpenseReport(parentReport) && !isEmptyObject(parentReportAction) && ReportActionsUtils.isTransactionThread(parentReportAction); } return false; @@ -1371,10 +1372,10 @@ function isExpenseRequest(report: OnyxEntry): boolean { * An IOU Request is a thread where the parent report is an IOU Report and * the parentReportAction is a transaction. */ -function isIOURequest(report: OnyxEntry): boolean { +function isIOURequest(report: OnyxInputOrEntry): boolean { if (isThread(report)) { const parentReportAction = ReportActionsUtils.getParentReportAction(report); - const parentReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${report?.parentReportID}`] ?? null; + const parentReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${report?.parentReportID}`]; return isIOUReport(parentReport) && !isEmptyObject(parentReportAction) && ReportActionsUtils.isTransactionThread(parentReportAction); } return false; @@ -1384,7 +1385,7 @@ function isIOURequest(report: OnyxEntry): boolean { * A Track Expense Report is a thread where the parent the parentReportAction is a transaction, and * parentReportAction has type of track. */ -function isTrackExpenseReport(report: OnyxEntry): boolean { +function isTrackExpenseReport(report: OnyxInputOrEntry): boolean { if (isThread(report)) { const parentReportAction = ReportActionsUtils.getParentReportAction(report); return !isEmptyObject(parentReportAction) && ReportActionsUtils.isTrackExpenseAction(parentReportAction); @@ -1403,8 +1404,8 @@ function isMoneyRequest(reportOrID: OnyxEntry | string): boolean { /** * Checks if a report is an IOU or expense report. */ -function isMoneyRequestReport(reportOrID: OnyxEntry | EmptyObject | string): boolean { - const report = typeof reportOrID === 'object' ? reportOrID : allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportOrID}`] ?? null; +function isMoneyRequestReport(reportOrID: OnyxInputOrEntry | EmptyObject | string): boolean { + const report = typeof reportOrID === 'string' ? allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportOrID}`] ?? null : reportOrID; return isIOUReport(report) || isExpenseReport(report); } @@ -1478,7 +1479,7 @@ function getReportNotificationPreference(report: OnyxEntry): string | nu /** * Checks if the current user is the action's author */ -function isActionCreator(reportAction: OnyxEntry | Partial): boolean { +function isActionCreator(reportAction: OnyxInputOrEntry | Partial): boolean { return reportAction?.actorAccountID === currentUserAccountID; } @@ -1486,7 +1487,7 @@ function isActionCreator(reportAction: OnyxEntry | Partial | Partial): NotificationPreference { +function getChildReportNotificationPreference(reportAction: OnyxInputOrEntry | Partial): NotificationPreference { const childReportNotificationPreference = reportAction?.childReportNotificationPreference ?? ''; if (childReportNotificationPreference) { return childReportNotificationPreference; @@ -1522,7 +1523,7 @@ function canAddOrDeleteTransactions(moneyRequestReport: OnyxEntry): bool * Can only delete if the author is this user and the action is an ADD_COMMENT action or an IOU action in an unsettled report, or if the user is a * policy admin */ -function canDeleteReportAction(reportAction: OnyxEntry, reportID: string): boolean { +function canDeleteReportAction(reportAction: OnyxInputOrEntry, reportID: string): boolean { const report = getReport(reportID); const isActionOwner = reportAction?.actorAccountID === currentUserAccountID; @@ -1610,7 +1611,7 @@ function getReportRecipientAccountIDs(report: OnyxEntry, currentLoginAcc // In 1:1 chat threads, the participants will be the same as parent report. If a report is specifically a 1:1 chat thread then we will // get parent report and use its participants array. if (isThread(report) && !(isTaskReport(report) || isMoneyRequestReport(report))) { - const parentReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${report?.parentReportID}`] ?? null; + const parentReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${report?.parentReportID}`]; if (isOneOnOneChat(parentReport)) { finalReport = parentReport; } @@ -1647,7 +1648,7 @@ function getReportRecipientAccountIDs(report: OnyxEntry, currentLoginAcc /** * Whether the time row should be shown for a report. */ -function canShowReportRecipientLocalTime(personalDetails: OnyxCollection, report: OnyxEntry, accountID: number): boolean { +function canShowReportRecipientLocalTime(personalDetails: OnyxEntry, report: OnyxEntry, accountID: number): boolean { const reportRecipientAccountIDs = getReportRecipientAccountIDs(report, accountID); const hasMultipleParticipants = reportRecipientAccountIDs.length > 1; const reportRecipient = personalDetails?.[reportRecipientAccountIDs[0]]; @@ -1731,7 +1732,7 @@ function getDefaultGroupAvatar(reportID?: string): IconAsset { * Returns the appropriate icons for the given chat report using the stored personalDetails. * The Avatar sources can be URLs or Icon components according to the chat type. */ -function getIconsForParticipants(participants: number[], personalDetails: OnyxCollection): Icon[] { +function getIconsForParticipants(participants: number[], personalDetails: OnyxInputOrEntry): Icon[] { const participantDetails: ParticipantDetails[] = []; const participantsList = participants || []; @@ -1774,7 +1775,7 @@ function getIconsForParticipants(participants: number[], personalDetails: OnyxCo /** * Given a report, return the associated workspace icon. */ -function getWorkspaceIcon(report: OnyxEntry, policy: OnyxEntry = null): Icon { +function getWorkspaceIcon(report: OnyxInputOrEntry, policy?: OnyxInputOrEntry): Icon { const workspaceName = getPolicyName(report, false, policy); const policyExpenseChatAvatarSource = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${report?.policyID}`]?.avatarURL ? allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${report?.policyID}`]?.avatarURL @@ -1920,12 +1921,12 @@ function getParticipants(reportID: string) { * The Avatar sources can be URLs or Icon components according to the chat type. */ function getIcons( - report: OnyxEntry, - personalDetails: OnyxCollection, + report: OnyxInputOrEntry, + personalDetails: OnyxInputOrEntry, defaultIcon: AvatarSource | null = null, defaultName = '', defaultAccountID = -1, - policy: OnyxEntry = null, + policy?: OnyxInputOrEntry, ): Icon[] { if (isEmptyObject(report)) { const fallbackIcon: Icon = { @@ -2325,7 +2326,7 @@ function hasNonReimbursableTransactions(iouReportID: string | undefined): boolea return transactions.filter((transaction) => transaction.reimbursable === false).length > 0; } -function getMoneyRequestSpendBreakdown(report: OnyxEntry, allReportsDict: OnyxCollection = null): SpendBreakdown { +function getMoneyRequestSpendBreakdown(report: OnyxInputOrEntry, allReportsDict?: OnyxCollection): SpendBreakdown { const allAvailableReports = allReportsDict ?? allReports; let moneyRequestReport; if (isMoneyRequestReport(report) || isInvoiceReport(report)) { @@ -2365,7 +2366,7 @@ function getMoneyRequestSpendBreakdown(report: OnyxEntry, allReportsDict /** * Get the title for a policy expense chat which depends on the role of the policy member seeing this report */ -function getPolicyExpenseChatName(report: OnyxEntry, policy: OnyxEntry | undefined = undefined): string | undefined { +function getPolicyExpenseChatName(report: OnyxEntry, policy?: OnyxEntry): string | undefined { const ownerAccountID = report?.ownerAccountID; const personalDetails = allPersonalDetails?.[ownerAccountID ?? -1]; const login = personalDetails ? personalDetails.login : null; @@ -2404,6 +2405,14 @@ function isReportFieldOfTypeTitle(reportField: OnyxEntry): bo return reportField?.type === 'formula' && reportField?.fieldID === CONST.REPORT_FIELD_TITLE_FIELD_ID; } +/** + * Check if Report has any held expenses + */ +function isHoldCreator(transaction: OnyxEntry, reportID: string): boolean { + const holdReportAction = ReportActionsUtils.getReportAction(reportID, `${transaction?.comment?.hold ?? ''}`); + return isActionCreator(holdReportAction); +} + /** * Check if report fields are available to use in a report */ @@ -2507,7 +2516,7 @@ function getAvailableReportFields(report: Report, policyReportFields: PolicyRepo /** * Get the title for an IOU or expense chat which will be showing the payer and the amount */ -function getMoneyRequestReportName(report: OnyxEntry, policy: OnyxEntry | undefined = undefined): string { +function getMoneyRequestReportName(report: OnyxEntry, policy?: OnyxEntry): string { const isReportSettled = isSettled(report?.reportID ?? ''); const reportFields = isReportSettled ? report?.fieldList : getReportFieldsByPolicyID(report?.policyID ?? ''); const titleReportField = getFormulaTypeReportField(reportFields ?? {}); @@ -2552,7 +2561,7 @@ function getMoneyRequestReportName(report: OnyxEntry, policy: OnyxEntry< * into a flat object. Used for displaying transactions and sending them in API commands */ -function getTransactionDetails(transaction: OnyxEntry, createdDateFormat: string = CONST.DATE.FNS_FORMAT_STRING): TransactionDetails | undefined { +function getTransactionDetails(transaction: OnyxInputOrEntry, createdDateFormat: string = CONST.DATE.FNS_FORMAT_STRING): TransactionDetails | undefined { if (!transaction) { return; } @@ -2596,7 +2605,7 @@ function getTransactionCommentObject(transaction: OnyxEntry): Comme * This is used in conjunction with canEditRestrictedField to control editing of specific fields like amount, currency, created, receipt, and distance. * On its own, it only controls allowing/disallowing navigating to the editing pages or showing/hiding the 'Edit' icon on report actions */ -function canEditMoneyRequest(reportAction: OnyxEntry): boolean { +function canEditMoneyRequest(reportAction: OnyxInputOrEntry): boolean { const isDeleted = ReportActionsUtils.isDeletedAction(reportAction); if (isDeleted) { @@ -2647,7 +2656,7 @@ function canEditMoneyRequest(reportAction: OnyxEntry): boolean { * Checks if the current user can edit the provided property of an expense * */ -function canEditFieldOfMoneyRequest(reportAction: OnyxEntry, fieldToEdit: ValueOf): boolean { +function canEditFieldOfMoneyRequest(reportAction: OnyxInputOrEntry, fieldToEdit: ValueOf): boolean { // A list of fields that cannot be edited by anyone, once an expense has been settled const restrictedFields: string[] = [ CONST.EDIT_REQUEST_FIELD.AMOUNT, @@ -2706,7 +2715,7 @@ function canEditFieldOfMoneyRequest(reportAction: OnyxEntry, field * - It's an expense where conditions for editability are defined in canEditMoneyRequest method * - It's not pending deletion */ -function canEditReportAction(reportAction: OnyxEntry): boolean { +function canEditReportAction(reportAction: OnyxInputOrEntry): boolean { const isCommentOrIOU = reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT || reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.IOU; return !!( @@ -2721,6 +2730,71 @@ function canEditReportAction(reportAction: OnyxEntry): boolean { ); } +function canHoldUnholdReportAction(reportAction: OnyxInputOrEntry): {canHoldRequest: boolean; canUnholdRequest: boolean} { + if (reportAction?.actionName !== CONST.REPORT.ACTIONS.TYPE.IOU) { + return {canHoldRequest: false, canUnholdRequest: false}; + } + + const moneyRequestReportID = reportAction?.originalMessage?.IOUReportID ?? 0; + const moneyRequestReport = getReport(String(moneyRequestReportID)); + + if (!moneyRequestReportID || !moneyRequestReport) { + return {canHoldRequest: false, canUnholdRequest: false}; + } + + const isRequestSettled = isSettled(moneyRequestReport?.reportID); + const isApproved = isReportApproved(moneyRequestReport); + const transactionID = moneyRequestReport ? reportAction?.originalMessage?.IOUTransactionID : 0; + const transaction = allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`] ?? ({} as Transaction); + + const parentReport = getReport(String(moneyRequestReport.parentReportID)); + const parentReportAction = ReportActionsUtils.getParentReportAction(moneyRequestReport); + + const isRequestIOU = parentReport?.type === 'iou'; + const isRequestHoldCreator = isHoldCreator(transaction, moneyRequestReport?.reportID) && isRequestIOU; + const isTrackExpenseMoneyReport = isTrackExpenseReport(moneyRequestReport); + const isActionOwner = + typeof parentReportAction?.actorAccountID === 'number' && + typeof currentUserPersonalDetails?.accountID === 'number' && + parentReportAction.actorAccountID === currentUserPersonalDetails?.accountID; + const isApprover = isMoneyRequestReport(moneyRequestReport) && moneyRequestReport?.managerID !== null && currentUserPersonalDetails?.accountID === moneyRequestReport?.managerID; + const isOnHold = TransactionUtils.isOnHold(transaction); + const isScanning = TransactionUtils.hasReceipt(transaction) && TransactionUtils.isReceiptBeingScanned(transaction); + + const canModifyStatus = !isTrackExpenseMoneyReport && (isPolicyAdmin || isActionOwner || isApprover); + const isDeletedParentAction = isEmptyObject(parentReportAction) || ReportActionsUtils.isDeletedAction(parentReportAction); + + const canHoldOrUnholdRequest = !isRequestSettled && !isApproved && !isDeletedParentAction; + const canHoldRequest = canHoldOrUnholdRequest && !isOnHold && (isRequestHoldCreator || (!isRequestIOU && canModifyStatus)) && !isScanning && !!transaction?.reimbursable; + const canUnholdRequest = !!(canHoldOrUnholdRequest && isOnHold && (isRequestHoldCreator || (!isRequestIOU && canModifyStatus))) && !!transaction?.reimbursable; + + return {canHoldRequest, canUnholdRequest}; +} + +const changeMoneyRequestHoldStatus = (reportAction: OnyxEntry): void => { + if (reportAction?.actionName !== CONST.REPORT.ACTIONS.TYPE.IOU) { + return; + } + const moneyRequestReportID = reportAction?.originalMessage?.IOUReportID ?? 0; + + const moneyRequestReport = getReport(String(moneyRequestReportID)); + if (!moneyRequestReportID || !moneyRequestReport) { + return; + } + + const transactionID = reportAction?.originalMessage?.IOUTransactionID ?? ''; + const transaction = allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`] ?? ({} as Transaction); + const isOnHold = TransactionUtils.isOnHold(transaction); + const policy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${moneyRequestReport.policyID}`] ?? null; + + if (isOnHold) { + IOU.unholdRequest(transactionID, reportAction.childReportID ?? ''); + } else { + const activeRoute = encodeURIComponent(Navigation.getActiveRouteWithoutParams()); + Navigation.navigate(ROUTES.MONEY_REQUEST_HOLD_REASON.getRoute(policy?.type ?? CONST.POLICY.TYPE.PERSONAL, transactionID, reportAction.childReportID ?? '', activeRoute)); + } +}; + /** * Gets all transactions on an IOU report with a receipt */ @@ -2841,13 +2915,13 @@ function getTransactionReportName(reportAction: OnyxEntry | EmptyObject, - iouReportAction: OnyxEntry | EmptyObject = {}, + report: OnyxInputOrEntry | EmptyObject, + iouReportAction: OnyxInputOrEntry | EmptyObject = {}, shouldConsiderScanningReceiptOrPendingRoute = false, isPreviewMessageForParentChatReport = false, - policy: OnyxEntry = null, + policy?: OnyxInputOrEntry, isForListPreview = false, - originalReportAction: OnyxEntry | EmptyObject = iouReportAction, + originalReportAction: OnyxInputOrEntry | EmptyObject = iouReportAction, ): string { const reportActionMessage = iouReportAction?.message?.[0]?.html ?? ''; @@ -2998,10 +3072,10 @@ function getReportPreviewMessage( * At the moment, we only allow changing one transaction field at a time. */ function getModifiedExpenseOriginalMessage( - oldTransaction: OnyxEntry, + oldTransaction: OnyxInputOrEntry, transactionChanges: TransactionChanges, isFromExpenseReport: boolean, - policy: OnyxEntry, + policy: OnyxInputOrEntry, ): ModifiedExpense { const originalMessage: ModifiedExpense = {}; // Remark: Comment field is the only one which has new/old prefixes for the keys (newComment/ oldComment), @@ -3220,7 +3294,7 @@ function getInvoicesChatName(report: OnyxEntry): string { /** * Get the title for a report. */ -function getReportName(report: OnyxEntry, policy: OnyxEntry = null): string { +function getReportName(report: OnyxEntry, policy?: OnyxEntry): string { let formattedName: string | undefined; const parentReportAction = ReportActionsUtils.getParentReportAction(report); if (isChatThread(report)) { @@ -3236,7 +3310,7 @@ function getReportName(report: OnyxEntry, policy: OnyxEntry = nu return Localize.translateLocal('parentReportAction.deletedMessage'); } - const isAttachment = ReportActionsUtils.isReportActionAttachment(!isEmptyObject(parentReportAction) ? parentReportAction : null); + const isAttachment = ReportActionsUtils.isReportActionAttachment(!isEmptyObject(parentReportAction) ? parentReportAction : undefined); const parentReportActionMessage = getReportActionMessage(parentReportAction, report?.parentReportID, report?.reportID ?? '').replace(/(\r\n|\n|\r)/gm, ' '); if (isAttachment && parentReportActionMessage) { return `[${Localize.translateLocal('common.attachment')}]`; @@ -3570,7 +3644,7 @@ function buildOptimisticAddCommentReportAction( htmlForNewComment = commentText; textForNewComment = parser.htmlToText(htmlForNewComment); } else { - htmlForNewComment = `${commentText}\n${CONST.ATTACHMENT_UPLOADING_MESSAGE_HTML}`; + htmlForNewComment = `${commentText}${CONST.ATTACHMENT_UPLOADING_MESSAGE_HTML}`; textForNewComment = `${parser.htmlToText(commentText)}\n${CONST.ATTACHMENT_UPLOADING_MESSAGE_HTML}`; } @@ -3935,12 +4009,12 @@ function getIOUReportActionMessage(iouReportID: string, type: string, total: num const report = getReport(iouReportID); if (type === CONST.REPORT.ACTIONS.TYPE.SUBMITTED) { - return getIOUSubmittedMessage(!isEmptyObject(report) ? report : null); + return getIOUSubmittedMessage(!isEmptyObject(report) ? report : undefined); } const amount = type === CONST.IOU.REPORT_ACTION_TYPE.PAY - ? CurrencyUtils.convertToDisplayString(getMoneyRequestSpendBreakdown(!isEmptyObject(report) ? report : null).totalDisplaySpend, currency) + ? CurrencyUtils.convertToDisplayString(getMoneyRequestSpendBreakdown(!isEmptyObject(report) ? report : undefined).totalDisplaySpend, currency) : CurrencyUtils.convertToDisplayString(total, currency); let paymentMethodMessage; @@ -4202,7 +4276,13 @@ function buildOptimisticSubmittedReportAction(amount: number, currency: string, * @param [comment] - User comment for the IOU. * @param [transaction] - optimistic first transaction of preview */ -function buildOptimisticReportPreview(chatReport: OnyxEntry, iouReport: Report, comment = '', transaction: OnyxEntry = null, childReportID?: string): ReportAction { +function buildOptimisticReportPreview( + chatReport: OnyxInputOrEntry, + iouReport: Report, + comment = '', + transaction?: OnyxInputOrEntry, + childReportID?: string, +): ReportAction { const hasReceipt = TransactionUtils.hasReceipt(transaction); const isReceiptBeingScanned = hasReceipt && TransactionUtils.isReceiptBeingScanned(transaction); const message = getReportPreviewMessage(iouReport); @@ -4278,11 +4358,11 @@ function buildOptimisticActionableTrackExpenseWhisper(iouAction: OptimisticIOURe * Builds an optimistic modified expense action with a randomly generated reportActionID. */ function buildOptimisticModifiedExpenseReportAction( - transactionThread: OnyxEntry, - oldTransaction: OnyxEntry, + transactionThread: OnyxInputOrEntry, + oldTransaction: OnyxInputOrEntry, transactionChanges: TransactionChanges, isFromExpenseReport: boolean, - policy: OnyxEntry, + policy: OnyxInputOrEntry, ): OptimisticModifiedExpenseReportAction { const originalMessage = getModifiedExpenseOriginalMessage(oldTransaction, transactionChanges, isFromExpenseReport, policy); return { @@ -4365,7 +4445,7 @@ function updateReportPreview( reportPreviewAction: ReportPreviewAction, isPayRequest = false, comment = '', - transaction: OnyxEntry = null, + transaction?: OnyxEntry, ): ReportPreviewAction { const hasReceipt = TransactionUtils.hasReceipt(transaction); const recentReceiptTransactions = reportPreviewAction?.childRecentReceiptTransactionIDs ?? {}; @@ -4456,13 +4536,13 @@ function buildOptimisticTaskReportAction( function buildOptimisticChatReport( participantList: number[], reportName: string = CONST.REPORT.DEFAULT_REPORT_NAME, - chatType: ValueOf | undefined = undefined, + chatType?: ValueOf, policyID: string = CONST.POLICY.OWNER_EMAIL_FAKE, ownerAccountID: number = CONST.REPORT.OWNER_ACCOUNT_ID_FAKE, isOwnPolicyExpenseChat = false, oldPolicyName = '', - visibility: ValueOf | undefined = undefined, - writeCapability: ValueOf | undefined = undefined, + visibility?: ValueOf, + writeCapability?: ValueOf, notificationPreference: NotificationPreference = CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS, parentReportActionID = '', parentReportID = '', @@ -5113,7 +5193,7 @@ function isUnread(report: OnyxEntry): boolean { return lastReadTime < lastVisibleActionCreated || lastReadTime < lastMentionedTime; } -function isIOUOwnedByCurrentUser(report: OnyxEntry, allReportsDict: OnyxCollection = null): boolean { +function isIOUOwnedByCurrentUser(report: OnyxEntry, allReportsDict?: OnyxCollection): boolean { const allAvailableReports = allReportsDict ?? allReports; if (!report || !allAvailableReports) { return false; @@ -5177,7 +5257,7 @@ function canAccessReport(report: OnyxEntry, policies: OnyxCollection, currentReportId: string): boolean { const currentReport = getReport(currentReportId); - const parentReport = getParentReport(!isEmptyObject(currentReport) ? currentReport : null); + const parentReport = getParentReport(!isEmptyObject(currentReport) ? currentReport : undefined); const reportActions = allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report?.reportID}`] ?? {}; const isChildReportHasComment = Object.values(reportActions ?? {})?.some((reportAction) => (reportAction?.childVisibleActionCount ?? 0) > 0); return parentReport?.reportID !== report?.reportID && !isChildReportHasComment; @@ -5186,7 +5266,11 @@ function shouldHideReport(report: OnyxEntry, currentReportId: string): b /** * Checks to see if a report's parentAction is an expense that contains a violation type of either violation or warning */ -function doesTransactionThreadHaveViolations(report: OnyxEntry, transactionViolations: OnyxCollection, parentReportAction: OnyxEntry): boolean { +function doesTransactionThreadHaveViolations( + report: OnyxInputOrEntry, + transactionViolations: OnyxCollection, + parentReportAction: OnyxInputOrEntry, +): boolean { if (parentReportAction?.actionName !== CONST.REPORT.ACTIONS.TYPE.IOU) { return false; } @@ -5364,70 +5448,75 @@ function shouldReportBeInOptionList({ return true; } +/** + * Returns the system report from the list of reports. + */ +function getSystemChat(): OnyxEntry { + if (!allReports) { + return undefined; + } + + return Object.values(allReports ?? {}).find((report) => report?.chatType === CONST.REPORT.CHAT_TYPE.SYSTEM); +} + /** * Attempts to find a report in onyx with the provided list of participants. Does not include threads, task, expense, room, and policy expense chat. */ function getChatByParticipants(newParticipantList: number[], reports: OnyxCollection = allReports): OnyxEntry { const sortedNewParticipantList = newParticipantList.sort(); - return ( - Object.values(reports ?? {}).find((report) => { - const participantAccountIDs = Object.keys(report?.participants ?? {}); - - // If the report has been deleted, or there are no participants (like an empty #admins room) then skip it - if ( - participantAccountIDs.length === 0 || - isChatThread(report) || - isTaskReport(report) || - isMoneyRequestReport(report) || - isChatRoom(report) || - isPolicyExpenseChat(report) || - isGroupChat(report) - ) { - return false; - } + return Object.values(reports ?? {}).find((report) => { + const participantAccountIDs = Object.keys(report?.participants ?? {}); - const sortedParticipantsAccountIDs = participantAccountIDs.map(Number).sort(); + // If the report has been deleted, or there are no participants (like an empty #admins room) then skip it + if ( + participantAccountIDs.length === 0 || + isChatThread(report) || + isTaskReport(report) || + isMoneyRequestReport(report) || + isChatRoom(report) || + isPolicyExpenseChat(report) || + isGroupChat(report) + ) { + return false; + } - // Only return the chat if it has all the participants - return lodashIsEqual(sortedNewParticipantList, sortedParticipantsAccountIDs); - }) ?? null - ); + const sortedParticipantsAccountIDs = participantAccountIDs.map(Number).sort(); + + // Only return the chat if it has all the participants + return lodashIsEqual(sortedNewParticipantList, sortedParticipantsAccountIDs); + }); } /** * Attempts to find an invoice chat report in onyx with the provided policyID and receiverID. */ function getInvoiceChatByParticipants(policyID: string, receiverID: string | number, reports: OnyxCollection = allReports): OnyxEntry { - return ( - Object.values(reports ?? {}).find((report) => { - if (!report || !isInvoiceRoom(report)) { - return false; - } + return Object.values(reports ?? {}).find((report) => { + if (!report || !isInvoiceRoom(report)) { + return false; + } - const isSameReceiver = - report.invoiceReceiver && - (('accountID' in report.invoiceReceiver && report.invoiceReceiver.accountID === receiverID) || - ('policyID' in report.invoiceReceiver && report.invoiceReceiver.policyID === receiverID)); + const isSameReceiver = + report.invoiceReceiver && + (('accountID' in report.invoiceReceiver && report.invoiceReceiver.accountID === receiverID) || + ('policyID' in report.invoiceReceiver && report.invoiceReceiver.policyID === receiverID)); - return report.policyID === policyID && isSameReceiver; - }) ?? null - ); + return report.policyID === policyID && isSameReceiver; + }); } /** * Attempts to find a policy expense report in onyx that is owned by ownerAccountID in a given policy */ function getPolicyExpenseChat(ownerAccountID: number, policyID: string): OnyxEntry { - return ( - Object.values(allReports ?? {}).find((report: OnyxEntry) => { - // If the report has been deleted, then skip it - if (!report) { - return false; - } + return Object.values(allReports ?? {}).find((report: OnyxEntry) => { + // If the report has been deleted, then skip it + if (!report) { + return false; + } - return report.policyID === policyID && isPolicyExpenseChat(report) && report.ownerAccountID === ownerAccountID; - }) ?? null - ); + return report.policyID === policyID && isPolicyExpenseChat(report) && report.ownerAccountID === ownerAccountID; + }); } function getAllPolicyReports(policyID: string): Array> { @@ -5437,7 +5526,7 @@ function getAllPolicyReports(policyID: string): Array> { /** * Returns true if Chronos is one of the chat participants (1:1) */ -function chatIncludesChronos(report: OnyxEntry | EmptyObject): boolean { +function chatIncludesChronos(report: OnyxInputOrEntry | EmptyObject): boolean { const participantAccountIDs = Object.keys(report?.participants ?? {}).map(Number); return participantAccountIDs.includes(CONST.ACCOUNT_ID.CHRONOS); } @@ -5449,7 +5538,7 @@ function chatIncludesChronos(report: OnyxEntry | EmptyObject): boolean { * - It's a welcome message whisper * - It's an ADD_COMMENT that is not an attachment */ -function canFlagReportAction(reportAction: OnyxEntry, reportID: string | undefined): boolean { +function canFlagReportAction(reportAction: OnyxInputOrEntry, reportID: string | undefined): boolean { let report = getReport(reportID); // If the childReportID exists in reportAction and is equal to the reportID, @@ -5482,7 +5571,7 @@ function canFlagReportAction(reportAction: OnyxEntry, reportID: st /** * Whether flag comment page should show */ -function shouldShowFlagComment(reportAction: OnyxEntry, report: OnyxEntry): boolean { +function shouldShowFlagComment(reportAction: OnyxInputOrEntry, report: OnyxInputOrEntry): boolean { return ( canFlagReportAction(reportAction, report?.reportID) && !isArchivedRoom(report) && @@ -5593,7 +5682,7 @@ function getReportPolicyID(reportID?: string): string | undefined { /** * Check if the chat report is linked to an iou that is waiting for the current user to add a credit bank account. */ -function hasIOUWaitingOnCurrentUserBankAccount(chatReport: OnyxEntry): boolean { +function hasIOUWaitingOnCurrentUserBankAccount(chatReport: OnyxInputOrEntry): boolean { if (chatReport?.iouReportID) { const iouReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${chatReport?.iouReportID}`]; if (iouReport?.isWaitingOnBankAccount && iouReport?.ownerAccountID === currentUserAccountID) { @@ -5961,7 +6050,7 @@ function canUserPerformWriteAction(report: OnyxEntry, reportNameValuePai /** * Returns ID of the original report from which the given reportAction is first created. */ -function getOriginalReportID(reportID: string, reportAction: OnyxEntry): string | undefined { +function getOriginalReportID(reportID: string, reportAction: OnyxInputOrEntry): string | undefined { const reportActions = allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`]; const currentReportAction = reportActions?.[reportAction?.reportActionID ?? ''] ?? null; const transactionThreadReportID = ReportActionsUtils.getOneTransactionThreadReportID(reportID, reportActions ?? ([] as ReportAction[])); @@ -5991,7 +6080,7 @@ function getReportOfflinePendingActionAndErrors(report: OnyxEntry): Repo /** * Check if the report can create the expense with type is iouType */ -function canCreateRequest(report: OnyxEntry, policy: OnyxEntry, iouType: (typeof CONST.IOU.TYPE)[keyof typeof CONST.IOU.TYPE]): boolean { +function canCreateRequest(report: OnyxEntry, policy: OnyxEntry, iouType: ValueOf): boolean { const participantAccountIDs = Object.keys(report?.participants ?? {}).map(Number); if (!canUserPerformWriteAction(report)) { return false; @@ -6038,7 +6127,7 @@ function shouldDisableRename(report: OnyxEntry, policy: OnyxEntry, policy: OnyxEntry): boolean { - return PolicyUtils.isPolicyAdmin(policy) && !isAdminRoom(report) && !isArchivedRoom(report) && !isThread(report); + return PolicyUtils.isPolicyAdmin(policy) && !isAdminRoom(report) && !isArchivedRoom(report) && !isThread(report) && !isInvoiceRoom(report); } /** @@ -6091,18 +6180,27 @@ function getTaskAssigneeChatOnyxData( }, ); - successData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${assigneeChatReportID}`, - value: { - pendingFields: { - createChat: null, + // BE will send different report's participants and assigneeAccountID. We clear the optimistic ones to avoid duplicated entries + successData.push( + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${assigneeChatReportID}`, + value: { + pendingFields: { + createChat: null, + }, + isOptimisticReport: false, + participants: {[assigneeAccountID]: null}, }, - isOptimisticReport: false, - // BE will send a different participant. We clear the optimistic one to avoid duplicated entries - participants: {[assigneeAccountID]: null}, }, - }); + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.PERSONAL_DETAILS_LIST, + value: { + [assigneeAccountID]: null, + }, + }, + ); failureData.push( { @@ -6205,7 +6303,7 @@ function getIOUReportActionDisplayMessage(reportAction: OnyxEntry, return Localize.translateLocal(translationKey, {amount: formattedAmount, payer: ''}); } - const transactionDetails = getTransactionDetails(!isEmptyObject(transaction) ? transaction : null); + const transactionDetails = getTransactionDetails(!isEmptyObject(transaction) ? transaction : undefined); const formattedAmount = CurrencyUtils.convertToDisplayString(transactionDetails?.amount ?? 0, transactionDetails?.currency); const isRequestSettled = isSettled(originalMessage.IOUReportID); const isApproved = isReportApproved(iouReport); @@ -6377,14 +6475,6 @@ function navigateToPrivateNotes(report: OnyxEntry, session: OnyxEntry, reportID: string): boolean { - const holdReportAction = ReportActionsUtils.getReportAction(reportID, `${transaction?.comment?.hold ?? ''}`); - return isActionCreator(holdReportAction); -} - /** * Get all held transactions of a iouReport */ @@ -6412,7 +6502,7 @@ function hasOnlyHeldExpenses(iouReportID: string): boolean { /** * Checks if thread replies should be displayed */ -function shouldDisplayThreadReplies(reportAction: OnyxEntry, reportID: string): boolean { +function shouldDisplayThreadReplies(reportAction: OnyxInputOrEntry, reportID: string): boolean { const hasReplies = (reportAction?.childVisibleActionCount ?? 0) > 0; return hasReplies && !!reportAction?.childCommenterCount && !isThreadFirstChat(reportAction, reportID); } @@ -6420,7 +6510,7 @@ function shouldDisplayThreadReplies(reportAction: OnyxEntry, repor /** * Check if money report has any transactions updated optimistically */ -function hasUpdatedTotal(report: OnyxEntry, policy: OnyxEntry): boolean { +function hasUpdatedTotal(report: OnyxInputOrEntry, policy: OnyxInputOrEntry): boolean { if (!report) { return true; } @@ -6436,7 +6526,7 @@ function hasUpdatedTotal(report: OnyxEntry, policy: OnyxEntry): /** * Return held and full amount formatted with used currency */ -function getNonHeldAndFullAmount(iouReport: OnyxEntry, policy: OnyxEntry): string[] { +function getNonHeldAndFullAmount(iouReport: OnyxInputOrEntry, policy: OnyxInputOrEntry): string[] { const transactions = TransactionUtils.getAllReportTransactions(iouReport?.reportID ?? ''); const hasPendingTransaction = transactions.some((transaction) => !!transaction.pendingAction); @@ -6462,7 +6552,7 @@ function getNonHeldAndFullAmount(iouReport: OnyxEntry, policy: OnyxEntry * - The action is a whisper action and it's neither a report preview nor IOU action * - The action is the thread's first chat */ -function shouldDisableThread(reportAction: OnyxEntry, reportID: string): boolean { +function shouldDisableThread(reportAction: OnyxInputOrEntry, reportID: string): boolean { const isSplitBillAction = ReportActionsUtils.isSplitBillAction(reportAction); const isDeletedAction = ReportActionsUtils.isDeletedAction(reportAction); const isReportPreviewAction = ReportActionsUtils.isReportPreviewAction(reportAction); @@ -6600,11 +6690,11 @@ function getOptimisticDataForParentReportAction(reportID: string, lastVisibleAct }); } -function canBeAutoReimbursed(report: OnyxEntry, policy: OnyxEntry | EmptyObject): boolean { +function canBeAutoReimbursed(report: OnyxInputOrEntry, policy: OnyxInputOrEntry | EmptyObject): boolean { if (isEmptyObject(policy)) { return false; } - type CurrencyType = (typeof CONST.DIRECT_REIMBURSEMENT_CURRENCIES)[number]; + type CurrencyType = TupleToUnion; const reimbursableTotal = getMoneyRequestSpendBreakdown(report).totalDisplaySpend; const autoReimbursementLimit = policy.autoReimbursementLimit ?? 0; const isAutoReimbursable = @@ -6617,7 +6707,7 @@ function canBeAutoReimbursed(report: OnyxEntry, policy: OnyxEntry): boolean { +function isReportOwner(report: OnyxInputOrEntry): boolean { return report?.ownerAccountID === currentUserPersonalDetails?.accountID; } @@ -6668,7 +6758,7 @@ function hasMissingPaymentMethod(userWallet: OnyxEntry, iouReportID: * - we have one, but it's waiting on the payee adding a bank account * - we have one, but we can't add more transactions to it due to: report is approved or settled, or report is processing and policy isn't on Instant submit reporting frequency */ -function shouldCreateNewMoneyRequestReport(existingIOUReport: OnyxEntry | undefined | null, chatReport: OnyxEntry | null): boolean { +function shouldCreateNewMoneyRequestReport(existingIOUReport: OnyxInputOrEntry | undefined, chatReport: OnyxInputOrEntry): boolean { return !existingIOUReport || hasIOUWaitingOnCurrentUserBankAccount(chatReport) || !canAddOrDeleteTransactions(existingIOUReport); } @@ -6687,14 +6777,14 @@ function hasActionsWithErrors(reportID: string): boolean { return Object.values(reportActions).some((action) => !isEmptyObject(action.errors)); } -function isNonAdminOrOwnerOfPolicyExpenseChat(report: OnyxEntry, policy: OnyxEntry): boolean { +function isNonAdminOrOwnerOfPolicyExpenseChat(report: OnyxInputOrEntry, policy: OnyxInputOrEntry): boolean { return isPolicyExpenseChat(report) && !(PolicyUtils.isPolicyAdmin(policy) || PolicyUtils.isPolicyOwner(policy, currentUserAccountID ?? -1) || isReportOwner(report)); } /** * Whether the user can join a report */ -function canJoinChat(report: OnyxEntry, parentReportAction: OnyxEntry, policy: OnyxEntry): boolean { +function canJoinChat(report: OnyxInputOrEntry, parentReportAction: OnyxInputOrEntry, policy: OnyxInputOrEntry): boolean { // We disabled thread functions for whisper action // So we should not show join option for existing thread on whisper message that has already been left, or manually leave it if (ReportActionsUtils.isWhisperAction(parentReportAction)) { @@ -6744,7 +6834,7 @@ function canLeaveChat(report: OnyxEntry, policy: OnyxEntry): boo return (isChatThread(report) && !!report?.notificationPreference?.length) || isUserCreatedPolicyRoom(report) || isNonAdminOrOwnerOfPolicyExpenseChat(report, policy); } -function getReportActionActorAccountID(reportAction: OnyxEntry, iouReport: OnyxEntry | undefined): number | undefined { +function getReportActionActorAccountID(reportAction: OnyxInputOrEntry, iouReport: OnyxInputOrEntry | undefined): number | undefined { switch (reportAction?.actionName) { case CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW: return iouReport ? iouReport.managerID : reportAction?.actorAccountID; @@ -6819,7 +6909,7 @@ function createDraftTransactionAndNavigateToParticipantSelector(transactionID: s /** * @returns the object to update `report.hasOutstandingChildRequest` */ -function getOutstandingChildRequest(iouReport: OnyxEntry | EmptyObject): OutstandingChildRequest { +function getOutstandingChildRequest(iouReport: OnyxInputOrEntry | EmptyObject): OutstandingChildRequest { if (!iouReport || isEmptyObject(iouReport)) { return {}; } @@ -6896,6 +6986,7 @@ export { canCreateTaskInReport, canCurrentUserOpenReport, canDeleteReportAction, + canHoldUnholdReportAction, canEditFieldOfMoneyRequest, canEditMoneyRequest, canEditPolicyDescription, @@ -6983,6 +7074,7 @@ export { getRoomWelcomeMessage, getRootParentReport, getRouteFromLink, + getSystemChat, getTaskAssigneeChatOnyxData, getTransactionDetails, getTransactionReportName, @@ -7123,6 +7215,7 @@ export { shouldShowMerchantColumn, isCurrentUserInvoiceReceiver, isDraftReport, + changeMoneyRequestHoldStatus, createDraftWorkspaceAndNavigateToConfirmationScreen, }; diff --git a/src/libs/SidebarUtils.ts b/src/libs/SidebarUtils.ts index 3df823db22df..119bbc3ba12a 100644 --- a/src/libs/SidebarUtils.ts +++ b/src/libs/SidebarUtils.ts @@ -12,6 +12,7 @@ import type PriorityMode from '@src/types/onyx/PriorityMode'; import type Report from '@src/types/onyx/Report'; import type ReportAction from '@src/types/onyx/ReportAction'; import type DeepValueOf from '@src/types/utils/DeepValueOf'; +import AccountUtils from './AccountUtils'; import * as CollectionUtils from './CollectionUtils'; import {hasValidDraftComment} from './DraftCommentUtils'; import localeCompare from './LocaleCompare'; @@ -104,6 +105,12 @@ function getOrderedReportIDs( return false; } + const participantAccountIDs = Object.keys(report?.participants ?? {}).map(Number); + + if (currentUserAccountID && AccountUtils.isAccountIDOddNumber(currentUserAccountID) && participantAccountIDs.includes(CONST.ACCOUNT_ID.NOTIFICATIONS)) { + return true; + } + return ReportUtils.shouldReportBeInOptionList({ report, currentReportId: currentReportId ?? '', diff --git a/src/libs/TransactionUtils.ts b/src/libs/TransactionUtils.ts index 6e2ee68b4eff..293a86072860 100644 --- a/src/libs/TransactionUtils.ts +++ b/src/libs/TransactionUtils.ts @@ -4,7 +4,7 @@ import Onyx from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {Policy, RecentWaypoint, Report, TaxRate, TaxRates, Transaction, TransactionViolation, TransactionViolations} from '@src/types/onyx'; +import type {OnyxInputOrEntry, Policy, RecentWaypoint, Report, TaxRate, TaxRates, Transaction, TransactionViolation, TransactionViolations} from '@src/types/onyx'; import type {Comment, Receipt, TransactionChanges, TransactionPendingFieldsKey, Waypoint, WaypointCollection} from '@src/types/onyx/Transaction'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import type {IOURequestType} from './actions/IOU'; @@ -158,7 +158,7 @@ function hasEReceipt(transaction: Transaction | undefined | null): boolean { return !!transaction?.hasEReceipt; } -function hasReceipt(transaction: OnyxEntry | undefined): boolean { +function hasReceipt(transaction: OnyxInputOrEntry | undefined): boolean { return !!transaction?.receipt?.state || hasEReceipt(transaction); } @@ -284,7 +284,7 @@ function getUpdatedTransaction(transaction: Transaction, transactionChanges: Tra * Return the comment field (referred to as description in the App) from the transaction. * The comment does not have its modifiedComment counterpart. */ -function getDescription(transaction: OnyxEntry): string { +function getDescription(transaction: OnyxInputOrEntry): string { // Casting the description to string to avoid wrong data types (e.g. number) being returned from the API return transaction?.comment?.comment?.toString() ?? ''; } @@ -292,7 +292,7 @@ function getDescription(transaction: OnyxEntry): string { /** * Return the amount field from the transaction, return the modifiedAmount if present. */ -function getAmount(transaction: OnyxEntry, isFromExpenseReport = false, isFromTrackedExpense = false): number { +function getAmount(transaction: OnyxInputOrEntry, isFromExpenseReport = false, isFromTrackedExpense = false): number { // IOU requests cannot have negative values, but they can be stored as negative values, let's return absolute value if (!isFromExpenseReport || isFromTrackedExpense) { const amount = transaction?.modifiedAmount ?? 0; @@ -318,7 +318,7 @@ function getAmount(transaction: OnyxEntry, isFromExpenseReport = fa /** * Return the tax amount field from the transaction. */ -function getTaxAmount(transaction: OnyxEntry, isFromExpenseReport: boolean): number { +function getTaxAmount(transaction: OnyxInputOrEntry, isFromExpenseReport: boolean): number { // IOU requests cannot have negative values but they can be stored as negative values, let's return absolute value if (!isFromExpenseReport) { return Math.abs(transaction?.taxAmount ?? 0); @@ -332,14 +332,14 @@ function getTaxAmount(transaction: OnyxEntry, isFromExpenseReport: /** * Return the tax code from the transaction. */ -function getTaxCode(transaction: OnyxEntry): string { +function getTaxCode(transaction: OnyxInputOrEntry): string { return transaction?.taxCode ?? ''; } /** * Return the currency field from the transaction, return the modifiedCurrency if present. */ -function getCurrency(transaction: OnyxEntry): string { +function getCurrency(transaction: OnyxInputOrEntry): string { const currency = transaction?.modifiedCurrency ?? ''; if (currency) { return currency; @@ -372,11 +372,11 @@ function isFetchingWaypointsFromServer(transaction: OnyxEntry): boo /** * Return the merchant field from the transaction, return the modifiedMerchant if present. */ -function getMerchant(transaction: OnyxEntry): string { +function getMerchant(transaction: OnyxInputOrEntry): string { return transaction?.modifiedMerchant ? transaction.modifiedMerchant : transaction?.merchant ?? ''; } -function getDistance(transaction: Transaction | null): number { +function getDistance(transaction: OnyxInputOrEntry): number { return transaction?.comment?.customUnit?.quantity ?? 0; } @@ -397,7 +397,7 @@ function getWaypoints(transaction: OnyxEntry): WaypointCollection | /** * Return the category from the transaction. This "category" field has no "modified" complement. */ -function getCategory(transaction: OnyxEntry): string { +function getCategory(transaction: OnyxInputOrEntry): string { return transaction?.category ?? ''; } @@ -411,7 +411,7 @@ function getCardID(transaction: Transaction): number { /** * Return the billable field from the transaction. This "billable" field has no "modified" complement. */ -function getBillable(transaction: OnyxEntry): boolean { +function getBillable(transaction: OnyxInputOrEntry): boolean { return transaction?.billable ?? false; } @@ -446,7 +446,7 @@ function getTagArrayFromName(tagName: string): string[] { * Return the tag from the transaction. When the tagIndex is passed, return the tag based on the index. * This "tag" field has no "modified" complement. */ -function getTag(transaction: OnyxEntry, tagIndex?: number): string { +function getTag(transaction: OnyxInputOrEntry, tagIndex?: number): string { if (tagIndex !== undefined) { const tagsArray = getTagArrayFromName(transaction?.tag ?? ''); return tagsArray[tagIndex] ?? ''; @@ -462,7 +462,7 @@ function getTagForDisplay(transaction: OnyxEntry, tagIndex?: number /** * Return the created field from the transaction, return the modifiedCreated if present. */ -function getCreated(transaction: OnyxEntry, dateFormat: string = CONST.DATE.FNS_FORMAT_STRING): string { +function getCreated(transaction: OnyxInputOrEntry, dateFormat: string = CONST.DATE.FNS_FORMAT_STRING): string { // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const created = transaction?.modifiedCreated ? transaction.modifiedCreated : transaction?.created || ''; @@ -507,7 +507,7 @@ function isPosted(transaction: Transaction): boolean { return transaction.status === CONST.TRANSACTION.STATUS.POSTED; } -function isReceiptBeingScanned(transaction: OnyxEntry): boolean { +function isReceiptBeingScanned(transaction: OnyxInputOrEntry): boolean { return [CONST.IOU.RECEIPT_STATE.SCANREADY, CONST.IOU.RECEIPT_STATE.SCANNING].some((value) => value === transaction?.receipt?.state); } @@ -518,7 +518,7 @@ function didRceiptScanSucceed(transaction: OnyxEntry): boolean { /** * Check if the transaction has a non-smartscanning receipt and is missing required fields */ -function hasMissingSmartscanFields(transaction: OnyxEntry): boolean { +function hasMissingSmartscanFields(transaction: OnyxInputOrEntry): boolean { return !!(transaction && !isDistanceRequest(transaction) && !isReceiptBeingScanned(transaction) && areRequiredFieldsEmpty(transaction)); } @@ -667,7 +667,7 @@ function isOnHoldByTransactionID(transactionID: string): boolean { return false; } - return isOnHold(allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`] ?? null); + return isOnHold(allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]); } /** @@ -683,7 +683,7 @@ function hasViolation(transactionID: string, transactionViolations: OnyxCollecti * Checks if any violations for the provided transaction are of type 'notice' */ function hasNoticeTypeViolation(transactionID: string, transactionViolations: OnyxCollection): boolean { - return !!transactionViolations?.[ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS + transactionID]?.some((violation: TransactionViolation) => violation.type === 'notice'); + return !!transactionViolations?.[ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS + transactionID]?.some((violation: TransactionViolation) => violation.type === CONST.VIOLATION_TYPES.NOTICE); } /** @@ -711,7 +711,7 @@ function getEnabledTaxRateCount(options: TaxRates) { /** * Check if the customUnitRateID has a value default for P2P distance requests */ -function isCustomUnitRateIDForP2P(transaction: OnyxEntry): boolean { +function isCustomUnitRateIDForP2P(transaction: OnyxInputOrEntry): boolean { return transaction?.comment?.customUnit?.customUnitRateID === CONST.CUSTOM_UNITS.FAKE_P2P_ID; } @@ -722,7 +722,7 @@ function hasReservationList(transaction: Transaction | undefined | null): boolea /** * Get rate ID from the transaction object */ -function getRateID(transaction: OnyxEntry): string | undefined { +function getRateID(transaction: OnyxInputOrEntry): string | undefined { return transaction?.comment?.customUnit?.customUnitRateID?.toString(); } diff --git a/src/libs/Violations/ViolationsUtils.ts b/src/libs/Violations/ViolationsUtils.ts index 2ceecb42dba5..686db5e6a6c5 100644 --- a/src/libs/Violations/ViolationsUtils.ts +++ b/src/libs/Violations/ViolationsUtils.ts @@ -7,7 +7,7 @@ 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 {PolicyCategories, PolicyTagList, Transaction, TransactionViolation} from '@src/types/onyx'; +import type {PolicyCategories, PolicyTagList, Transaction, TransactionViolation, ViolationName} from '@src/types/onyx'; /** * Calculates tag out of policy and missing tag violations for the given transaction @@ -49,17 +49,41 @@ function getTagViolationsForSingleLevelTags( } /** - * Calculates some tag levels required and missing tag violations for the given transaction + * Calculates missing tag violations for policies with dependent tags */ -function getTagViolationsForMultiLevelTags( - updatedTransaction: Transaction, - transactionViolations: TransactionViolation[], - policyRequiresTags: boolean, - policyTagList: PolicyTagList, -): TransactionViolation[] { +function getTagViolationsForDependentTags(policyTagList: PolicyTagList, transactionViolations: TransactionViolation[], tagName: string) { + const tagViolations = [...transactionViolations]; + + if (!tagName) { + Object.values(policyTagList).forEach((tagList) => + tagViolations.push({ + name: CONST.VIOLATIONS.MISSING_TAG, + type: CONST.VIOLATION_TYPES.VIOLATION, + data: {tagName: tagList.name}, + }), + ); + } else { + const tags = TransactionUtils.getTagArrayFromName(tagName); + if (Object.keys(policyTagList).length !== tags.length || tags.includes('')) { + tagViolations.push({ + name: CONST.VIOLATIONS.ALL_TAG_LEVELS_REQUIRED, + type: CONST.VIOLATION_TYPES.VIOLATION, + data: {}, + }); + } + } + + return tagViolations; +} + +/** + * Calculates missing tag violations for policies with independent tags + */ +function getTagViolationForIndependentTags(policyTagList: PolicyTagList, transactionViolations: TransactionViolation[], transaction: Transaction) { const policyTagKeys = getSortedTagKeys(policyTagList); - const selectedTags = updatedTransaction.tag?.split(CONST.COLON) ?? []; + const selectedTags = transaction.tag?.split(CONST.COLON) ?? []; let newTransactionViolations = [...transactionViolations]; + newTransactionViolations = newTransactionViolations.filter( (violation) => violation.name !== CONST.VIOLATIONS.SOME_TAG_LEVELS_REQUIRED && violation.name !== CONST.VIOLATIONS.TAG_OUT_OF_POLICY, ); @@ -109,6 +133,30 @@ function getTagViolationsForMultiLevelTags( return newTransactionViolations; } +/** + * Calculates tag violations for a transaction on a policy with multi level tags + */ +function getTagViolationsForMultiLevelTags( + updatedTransaction: Transaction, + transactionViolations: TransactionViolation[], + policyTagList: PolicyTagList, + hasDependentTags: boolean, +): TransactionViolation[] { + const tagViolations = [ + CONST.VIOLATIONS.SOME_TAG_LEVELS_REQUIRED, + CONST.VIOLATIONS.TAG_OUT_OF_POLICY, + CONST.VIOLATIONS.MISSING_TAG, + CONST.VIOLATIONS.ALL_TAG_LEVELS_REQUIRED, + ] as ViolationName[]; + const filteredTransactionViolations = transactionViolations.filter((violation) => !tagViolations.includes(violation.name)); + + if (hasDependentTags) { + return getTagViolationsForDependentTags(policyTagList, filteredTransactionViolations, updatedTransaction.tag ?? ''); + } + + return getTagViolationForIndependentTags(policyTagList, filteredTransactionViolations, updatedTransaction); +} + const ViolationsUtils = { /** * Checks a transaction for policy violations and returns an object with Onyx method, key and updated transaction @@ -121,6 +169,7 @@ const ViolationsUtils = { policyTagList: PolicyTagList, policyRequiresCategories: boolean, policyCategories: PolicyCategories, + hasDependentTags: boolean, ): OnyxUpdate { const isPartialTransaction = TransactionUtils.isPartialMerchant(TransactionUtils.getMerchant(updatedTransaction)) && TransactionUtils.isAmountMissing(updatedTransaction); if (isPartialTransaction) { @@ -166,7 +215,7 @@ const ViolationsUtils = { newTransactionViolations = Object.keys(policyTagList).length === 1 ? getTagViolationsForSingleLevelTags(updatedTransaction, newTransactionViolations, policyRequiresTags, policyTagList) - : getTagViolationsForMultiLevelTags(updatedTransaction, newTransactionViolations, policyRequiresTags, policyTagList); + : getTagViolationsForMultiLevelTags(updatedTransaction, newTransactionViolations, policyTagList, hasDependentTags); } return { diff --git a/src/libs/actions/App.ts b/src/libs/actions/App.ts index ae7fc115ac22..dbe32185b71e 100644 --- a/src/libs/actions/App.ts +++ b/src/libs/actions/App.ts @@ -44,24 +44,24 @@ type PolicyParamsForOpenOrReconnect = { type Locale = ValueOf; -let currentUserAccountID: number | null; +let currentUserAccountID: number | undefined; let currentUserEmail: string; Onyx.connect({ key: ONYXKEYS.SESSION, callback: (val) => { - currentUserAccountID = val?.accountID ?? null; + currentUserAccountID = val?.accountID; currentUserEmail = val?.email ?? ''; }, }); -let isSidebarLoaded: boolean | null; +let isSidebarLoaded: boolean | undefined; Onyx.connect({ key: ONYXKEYS.IS_SIDEBAR_LOADED, callback: (val) => (isSidebarLoaded = val), initWithStoredValues: false, }); -let preferredLocale: string | null = null; +let preferredLocale: string | undefined; Onyx.connect({ key: ONYXKEYS.NVP_PREFERRED_LOCALE, callback: (val) => { @@ -74,7 +74,7 @@ Onyx.connect({ }, }); -let priorityMode: ValueOf | null; +let priorityMode: ValueOf | undefined; Onyx.connect({ key: ONYXKEYS.NVP_PRIORITY_MODE, callback: (nextPriorityMode) => { diff --git a/src/libs/actions/BankAccounts.ts b/src/libs/actions/BankAccounts.ts index cddb2c371a60..772e99eea137 100644 --- a/src/libs/actions/BankAccounts.ts +++ b/src/libs/actions/BankAccounts.ts @@ -1,4 +1,3 @@ -import type {OnyxEntry} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; import type {FormInputErrors, FormOnyxValues} from '@components/Form/types'; import type {OnfidoDataWithApplicantID} from '@components/Onfido/types'; @@ -70,7 +69,7 @@ function openPlaidView() { clearPlaid().then(() => ReimbursementAccount.setBankAccountSubStep(CONST.BANK_ACCOUNT.SETUP_TYPE.PLAID)); } -function setPlaidEvent(eventName: OnyxEntry) { +function setPlaidEvent(eventName: string | null) { Onyx.set(ONYXKEYS.PLAID_CURRENT_EVENT, eventName); } @@ -87,10 +86,9 @@ function openPersonalBankAccountSetupView(exitReportID?: string) { } /** - * TODO: remove the previous function and rename this function to openPersonalBankAccountSetupView after migrating to the new flow - * Open the personal bank account setup flow, with an optional exitReportID to redirect to once the flow is finished. + * Open the personal bank account setup flow using Plaid, with an optional exitReportID to redirect to once the flow is finished. */ -function openPersonalBankAccountSetupViewRefactor(exitReportID?: string) { +function openPersonalBankAccountSetupWithPlaid(exitReportID?: string) { clearPlaid().then(() => { if (exitReportID) { Onyx.merge(ONYXKEYS.PERSONAL_BANK_ACCOUNT, {exitReportID}); @@ -232,6 +230,13 @@ function addPersonalBankAccount(account: PlaidBankAccount) { shouldShowSuccess: true, }, }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.USER_WALLET, + value: { + currentStep: CONST.WALLET.STEP.ADDITIONAL_DETAILS, + }, + }, ], failureData: [ { @@ -562,7 +567,7 @@ export { validateBankAccount, verifyIdentityForBankAccount, setReimbursementAccountLoading, - openPersonalBankAccountSetupViewRefactor, + openPersonalBankAccountSetupWithPlaid, updateAddPersonalBankAccountDraft, clearPersonalBankAccountSetupType, validatePlaidSelection, diff --git a/src/libs/actions/Device/index.ts b/src/libs/actions/Device/index.ts index ed700c06a97f..7ef3a4fd289f 100644 --- a/src/libs/actions/Device/index.ts +++ b/src/libs/actions/Device/index.ts @@ -20,8 +20,8 @@ function getDeviceID(): Promise { key: ONYXKEYS.DEVICE_ID, callback: (id) => { Onyx.disconnect(connectionID); - deviceID = id; - return resolve(id); + deviceID = id ?? null; + return resolve(id ?? null); }, }); }); diff --git a/src/libs/actions/FormActions.ts b/src/libs/actions/FormActions.ts index 1fcf9bed6a55..5fe1705d8db3 100644 --- a/src/libs/actions/FormActions.ts +++ b/src/libs/actions/FormActions.ts @@ -24,7 +24,7 @@ function clearErrorFields(formID: OnyxFormKey) { } function setDraftValues(formID: OnyxFormKey, draftValues: NullishDeep>) { - Onyx.merge(`${formID}Draft`, draftValues); + Onyx.merge(`${formID}Draft`, draftValues ?? null); } function clearDraftValues(formID: OnyxFormKey) { diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts index 1bd4de43acfb..e1b726be23c0 100644 --- a/src/libs/actions/IOU.ts +++ b/src/libs/actions/IOU.ts @@ -1,6 +1,6 @@ import {format} from 'date-fns'; import {fastMerge, Str} from 'expensify-common'; -import type {OnyxCollection, OnyxEntry, OnyxUpdate} from 'react-native-onyx'; +import type {OnyxCollection, OnyxEntry, OnyxInputValue, OnyxUpdate} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; import ReceiptGeneric from '@assets/images/receipt-generic.png'; @@ -165,11 +165,11 @@ Onyx.connect({ }, }); -let allReports: OnyxCollection = null; +let allReports: OnyxCollection | null = null; Onyx.connect({ key: ONYXKEYS.COLLECTION.REPORT, waitForCollectionCallback: true, - callback: (value) => (allReports = value), + callback: (value) => (allReports = value ?? null), }); let allTransactions: NonNullable> = {}; @@ -287,7 +287,7 @@ Onyx.connect({ /** * Find the report preview action from given chat report and iou report */ -function getReportPreviewAction(chatReportID: string, iouReportID: string): OnyxEntry { +function getReportPreviewAction(chatReportID: string, iouReportID: string): OnyxInputValue { const reportActions = allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReportID}`] ?? {}; // Find the report preview action from the chat report @@ -450,7 +450,7 @@ function getReceiptError(receipt?: Receipt, filename?: string, isScanRequest = t /** Builds the Onyx data for an expense */ function buildOnyxDataForMoneyRequest( - chatReport: OnyxEntry, + chatReport: OnyxTypes.OnyxInputOrEntry, iouReport: OnyxTypes.Report, transaction: OnyxTypes.Transaction, chatCreatedAction: OptimisticCreatedReportAction, @@ -464,9 +464,9 @@ function buildOnyxDataForMoneyRequest( transactionThreadReport: OptimisticChatReport | EmptyObject, transactionThreadCreatedReportAction: OptimisticCreatedReportAction | EmptyObject, shouldCreateNewMoneyRequestReport: boolean, - policy?: OnyxEntry, - policyTagList?: OnyxEntry, - policyCategories?: OnyxEntry, + policy?: OnyxTypes.OnyxInputOrEntry, + policyTagList?: OnyxTypes.OnyxInputOrEntry, + policyCategories?: OnyxTypes.OnyxInputOrEntry, optimisticNextStep?: OnyxTypes.ReportNextStep | null, isOneOnOneSplit = false, existingTransactionThreadReportID?: string, @@ -506,6 +506,7 @@ function buildOnyxDataForMoneyRequest( ...iouReport, lastMessageText: iouAction.message?.[0]?.text, lastMessageHtml: iouAction.message?.[0]?.html, + lastVisibleActionCreated: iouAction.created, pendingFields: { ...(shouldCreateNewMoneyRequestReport ? {createChat: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD} : {preview: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}), }, @@ -822,7 +823,15 @@ function buildOnyxDataForMoneyRequest( return [optimisticData, successData, failureData]; } - const violationsOnyxData = ViolationsUtils.getViolationsOnyxData(transaction, [], !!policy.requiresTag, policyTagList ?? {}, !!policy.requiresCategory, policyCategories ?? {}); + const violationsOnyxData = ViolationsUtils.getViolationsOnyxData( + transaction, + [], + !!policy.requiresTag, + policyTagList ?? {}, + !!policy.requiresCategory, + policyCategories ?? {}, + PolicyUtils.hasDependentTags(policy, policyTagList ?? {}), + ); if (violationsOnyxData) { optimisticData.push(violationsOnyxData); @@ -1136,7 +1145,15 @@ function buildOnyxDataForInvoice( return [optimisticData, successData, failureData]; } - const violationsOnyxData = ViolationsUtils.getViolationsOnyxData(transaction, [], !!policy.requiresTag, policyTagList ?? {}, !!policy.requiresCategory, policyCategories ?? {}); + const violationsOnyxData = ViolationsUtils.getViolationsOnyxData( + transaction, + [], + !!policy.requiresTag, + policyTagList ?? {}, + !!policy.requiresCategory, + policyCategories ?? {}, + PolicyUtils.hasDependentTags(policy, policyTagList ?? {}), + ); if (violationsOnyxData) { optimisticData.push(violationsOnyxData); @@ -1152,20 +1169,20 @@ function buildOnyxDataForInvoice( /** Builds the Onyx data for track expense */ function buildOnyxDataForTrackExpense( - chatReport: OnyxEntry, - iouReport: OnyxEntry, + chatReport: OnyxInputValue, + iouReport: OnyxInputValue, transaction: OnyxTypes.Transaction, iouCreatedAction: OptimisticCreatedReportAction, iouAction: OptimisticIOUReportAction, - reportPreviewAction: OnyxEntry, + reportPreviewAction: OnyxInputValue, transactionThreadReport: OptimisticChatReport | EmptyObject, transactionThreadCreatedReportAction: OptimisticCreatedReportAction | EmptyObject, shouldCreateNewMoneyRequestReport: boolean, - policy?: OnyxEntry, - policyTagList?: OnyxEntry, - policyCategories?: OnyxEntry, + policy?: OnyxInputValue, + policyTagList?: OnyxInputValue, + policyCategories?: OnyxInputValue, existingTransactionThreadReportID?: string, - actionableTrackExpenseWhisper?: OnyxEntry, + actionableTrackExpenseWhisper?: OnyxInputValue, ): [OnyxUpdate[], OnyxUpdate[], OnyxUpdate[]] { const isScanRequest = TransactionUtils.isScanRequest(transaction); const isDistanceRequest = TransactionUtils.isDistanceRequest(transaction); @@ -1402,7 +1419,7 @@ function buildOnyxDataForTrackExpense( failureData.push({ onyxMethod: Onyx.METHOD.SET, key: ONYXKEYS.NVP_QUICK_ACTION_GLOBAL_CREATE, - value: quickAction, + value: quickAction ?? null, }); if (iouReport) { @@ -1505,7 +1522,15 @@ function buildOnyxDataForTrackExpense( return [optimisticData, successData, failureData]; } - const violationsOnyxData = ViolationsUtils.getViolationsOnyxData(transaction, [], !!policy.requiresTag, policyTagList ?? {}, !!policy.requiresCategory, policyCategories ?? {}); + const violationsOnyxData = ViolationsUtils.getViolationsOnyxData( + transaction, + [], + !!policy.requiresTag, + policyTagList ?? {}, + !!policy.requiresCategory, + policyCategories ?? {}, + PolicyUtils.hasDependentTags(policy, policyTagList ?? {}), + ); if (violationsOnyxData) { optimisticData.push(violationsOnyxData); @@ -1639,7 +1664,7 @@ function getDeleteTrackExpenseInformation( failureData.push({ onyxMethod: Onyx.METHOD.SET, key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, - value: transaction, + value: transaction ?? null, }); } @@ -1647,7 +1672,7 @@ function getDeleteTrackExpenseInformation( failureData.push({ onyxMethod: Onyx.METHOD.SET, key: `${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`, - value: transactionViolations, + value: transactionViolations ?? null, }); } @@ -1722,7 +1747,7 @@ function getSendInvoiceInformation( let chatReport = !isEmptyObject(invoiceChatReport) && invoiceChatReport?.reportID ? invoiceChatReport : null; if (!chatReport) { - chatReport = ReportUtils.getInvoiceChatByParticipants(senderWorkspaceID, receiverAccountID); + chatReport = ReportUtils.getInvoiceChatByParticipants(senderWorkspaceID, receiverAccountID) ?? null; } if (!chatReport) { @@ -1875,7 +1900,7 @@ function getMoneyRequestInformation( } if (!chatReport) { - chatReport = ReportUtils.getChatByParticipants([payerAccountID, payeeAccountID]); + chatReport = ReportUtils.getChatByParticipants([payerAccountID, payeeAccountID]) ?? null; } // If we still don't have a report, it likely doens't exist and we need to build an optimistic one @@ -1886,7 +1911,7 @@ function getMoneyRequestInformation( // STEP 2: Get the Expense/IOU report. If the moneyRequestReportID has been provided, we want to add the transaction to this specific report. // If no such reportID has been provided, let's use the chatReport.iouReportID property. In case that is not present, build a new optimistic Expense/IOU report. - let iouReport: OnyxEntry = null; + let iouReport: OnyxInputValue = null; if (moneyRequestReportID) { iouReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${moneyRequestReportID}`] ?? null; } else { @@ -1983,6 +2008,7 @@ function getMoneyRequestInformation( reportPreviewAction = ReportUtils.updateReportPreview(iouReport, reportPreviewAction as ReportPreviewAction, false, comment, optimisticTransaction); } else { reportPreviewAction = ReportUtils.buildOptimisticReportPreview(chatReport, iouReport, comment, optimisticTransaction); + chatReport.lastVisibleActionCreated = reportPreviewAction.created; // Generated ReportPreview action is a parent report action of the iou report. // We are setting the iou report's parentReportActionID to display subtitle correctly in IOU page when offline. @@ -2112,7 +2138,7 @@ function getTrackExpenseInformation( // For this, first use the chatReport.iouReportID property. Build a new optimistic expense report if needed. const shouldUseMoneyReport = !!isPolicyExpenseChat; - let iouReport: OnyxEntry = null; + let iouReport: OnyxInputValue = null; let shouldCreateNewMoneyRequestReport = false; if (shouldUseMoneyReport) { @@ -2202,7 +2228,7 @@ function getTrackExpenseInformation( linkedTrackedExpenseReportAction, ); - let reportPreviewAction: OnyxEntry = null; + let reportPreviewAction: OnyxInputValue = null; if (shouldUseMoneyReport && iouReport) { reportPreviewAction = shouldCreateNewMoneyRequestReport ? null : getReportPreviewAction(chatReport.reportID, iouReport.reportID); @@ -2216,7 +2242,7 @@ function getTrackExpenseInformation( } } - let actionableTrackExpenseWhisper: OnyxEntry = null; + let actionableTrackExpenseWhisper: OnyxInputValue = null; if (!isPolicyExpenseChat) { actionableTrackExpenseWhisper = ReportUtils.buildOptimisticActionableTrackExpenseWhisper(iouAction, optimisticTransaction.transactionID); } @@ -2283,7 +2309,6 @@ function createDistanceRequest( const isMoneyRequestReport = ReportUtils.isMoneyRequestReport(report); const currentChatReport = isMoneyRequestReport ? ReportUtils.getReport(report?.chatReportID) : report; const moneyRequestReportID = isMoneyRequestReport ? report?.reportID : ''; - const currentCreated = DateUtils.enrichMoneyRequestTimestamp(created); const optimisticReceipt: Receipt = { source: ReceiptGeneric as ReceiptSource, @@ -2307,7 +2332,7 @@ function createDistanceRequest( comment, amount, currency, - currentCreated, + created, merchant, optimisticReceipt, undefined, @@ -2335,7 +2360,7 @@ function createDistanceRequest( createdIOUReportActionID, reportPreviewReportActionID: reportPreviewAction.reportActionID, waypoints: JSON.stringify(validWaypoints), - created: currentCreated, + created, category, tag, taxCode, @@ -2355,7 +2380,11 @@ function createDistanceRequest( /** * Compute the diff amount when we update the transaction */ -function calculateDiffAmount(iouReport: OnyxEntry, updatedTransaction: OnyxEntry, transaction: OnyxEntry): number { +function calculateDiffAmount( + iouReport: OnyxTypes.OnyxInputOrEntry, + updatedTransaction: OnyxTypes.OnyxInputOrEntry, + transaction: OnyxEntry, +): number { if (!iouReport) { return 0; } @@ -2383,10 +2412,10 @@ function calculateDiffAmount(iouReport: OnyxEntry, updatedTran } function calculateAmountForUpdatedWaypoint( - transaction: OnyxEntry, + transaction: OnyxTypes.OnyxInputOrEntry, transactionChanges: TransactionChanges, - policy: OnyxEntry, - iouReport: OnyxEntry, + policy: OnyxTypes.OnyxInputOrEntry, + iouReport: OnyxTypes.OnyxInputOrEntry, ) { let updatedAmount: number = CONST.IOU.DEFAULT_AMOUNT; let updatedMerchant = Localize.translateLocal('iou.fieldPending'); @@ -2425,9 +2454,9 @@ function getUpdateMoneyRequestParams( transactionID: string, transactionThreadReportID: string, transactionChanges: TransactionChanges, - policy: OnyxEntry, - policyTagList: OnyxEntry, - policyCategories: OnyxEntry, + policy: OnyxTypes.OnyxInputOrEntry, + policyTagList: OnyxTypes.OnyxInputOrEntry, + policyCategories: OnyxTypes.OnyxInputOrEntry, onlyIncludeChangedFields: boolean, ): UpdateMoneyRequestData { const optimisticData: OnyxUpdate[] = []; @@ -2675,6 +2704,7 @@ function getUpdateMoneyRequestParams( policyTagList ?? {}, !!policy.requiresCategory, policyCategories ?? {}, + PolicyUtils.hasDependentTags(policy, policyTagList ?? {}), ), ); failureData.push({ @@ -2713,7 +2743,7 @@ function getUpdateTrackExpenseParams( transactionThreadReportID: string, transactionChanges: TransactionChanges, onlyIncludeChangedFields: boolean, - policy: OnyxEntry, + policy: OnyxTypes.OnyxInputOrEntry, ): UpdateMoneyRequestData { const optimisticData: OnyxUpdate[] = []; const successData: OnyxUpdate[] = []; @@ -3390,7 +3420,6 @@ function requestMoney( const isMoneyRequestReport = ReportUtils.isMoneyRequestReport(report); const currentChatReport = isMoneyRequestReport ? ReportUtils.getReport(report?.chatReportID) : report; const moneyRequestReportID = isMoneyRequestReport ? report?.reportID : ''; - const currentCreated = DateUtils.enrichMoneyRequestTimestamp(created); const isMovingTransactionFromTrackExpense = IOUUtils.isMovingTransactionFromTrackExpense(action); const { @@ -3412,7 +3441,7 @@ function requestMoney( comment, amount, currency, - currentCreated, + created, merchant, receipt, isMovingTransactionFromTrackExpense ? (linkedTrackedExpenseReportAction?.originalMessage as IOUMessage)?.IOUTransactionID : undefined, @@ -3456,7 +3485,7 @@ function requestMoney( currency, comment, merchant, - currentCreated, + created, receipt, ); break; @@ -3468,7 +3497,7 @@ function requestMoney( amount, currency, comment, - created: currentCreated, + created, merchant, iouReportID: iouReport.reportID, chatReportID: chatReport.reportID, @@ -3571,7 +3600,6 @@ function trackExpense( const moneyRequestReportID = isMoneyRequestReport ? report.reportID : ''; const isMovingTransactionFromTrackExpense = IOUUtils.isMovingTransactionFromTrackExpense(action); - const currentCreated = DateUtils.enrichMoneyRequestTimestamp(created); const { createdWorkspaceParams, iouReport, @@ -3591,7 +3619,7 @@ function trackExpense( comment, amount, currency, - currentCreated, + created, merchant, receipt, category, @@ -3631,7 +3659,7 @@ function trackExpense( currency, comment, merchant, - currentCreated, + created, category, tag, taxCode, @@ -3662,7 +3690,7 @@ function trackExpense( currency, comment, merchant, - currentCreated, + created, category, tag, taxCode, @@ -3678,7 +3706,7 @@ function trackExpense( amount, currency, comment, - created: currentCreated, + created, merchant, iouReportID: iouReport?.reportID, chatReportID: chatReport.reportID, @@ -3835,6 +3863,7 @@ function createSplitsAndOnyxData( splitChatReport.lastMessageText = splitIOUReportAction.message?.[0]?.text; splitChatReport.lastMessageHtml = splitIOUReportAction.message?.[0]?.html; splitChatReport.lastActorAccountID = currentUserAccountID; + splitChatReport.lastVisibleActionCreated = splitIOUReportAction.created; let splitChatReportNotificationPreference = splitChatReport.notificationPreference; if (splitChatReportNotificationPreference === CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN) { @@ -4217,7 +4246,6 @@ function splitBill({ taxCode = '', taxAmount = 0, }: SplitBillActionsParams) { - const currentCreated = DateUtils.enrichMoneyRequestTimestamp(created); const {splitData, splits, onyxData} = createSplitsAndOnyxData( participants, currentUserLogin, @@ -4226,7 +4254,7 @@ function splitBill({ comment, currency, merchant, - currentCreated, + created, category, tag, splitShares, @@ -4245,7 +4273,7 @@ function splitBill({ comment, category, merchant, - created: currentCreated, + created, tag, billable, transactionID: splitData.transactionID, @@ -4285,7 +4313,6 @@ function splitBillAndOpenReport({ taxCode = '', taxAmount = 0, }: SplitBillActionsParams) { - const currentCreated = DateUtils.enrichMoneyRequestTimestamp(created); const {splitData, splits, onyxData} = createSplitsAndOnyxData( participants, currentUserLogin, @@ -4294,7 +4321,7 @@ function splitBillAndOpenReport({ comment, currency, merchant, - currentCreated, + created, category, tag, splitShares, @@ -4311,7 +4338,7 @@ function splitBillAndOpenReport({ splits: JSON.stringify(splits), currency, merchant, - created: currentCreated, + created, comment, category, tag, @@ -4639,7 +4666,7 @@ function startSplitBill({ API.write(WRITE_COMMANDS.START_SPLIT_BILL, parameters, {optimisticData, successData, failureData}); Navigation.dismissModalWithReport(splitChatReport); - Report.notifyNewAction(splitChatReport.chatReportID ?? '', currentUserAccountID); + Report.notifyNewAction(splitChatReport.reportID ?? '', currentUserAccountID); } /** Used for editing a split expense while it's still scanning or when SmartScan fails, it completes a split expense started by startSplitBill above. @@ -5130,6 +5157,7 @@ function editRegularMoneyRequest( policyTags, !!policy.requiresCategory, policyCategories, + PolicyUtils.hasDependentTags(policy, policyTags), ); optimisticData.push(updatedViolationsOnyxData); failureData.push({ @@ -5418,7 +5446,7 @@ function deleteMoneyRequest(transactionID: string, reportAction: OnyxTypes.Repor { onyxMethod: Onyx.METHOD.SET, key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, - value: transaction, + value: transaction ?? null, }, ]; @@ -5426,7 +5454,7 @@ function deleteMoneyRequest(transactionID: string, reportAction: OnyxTypes.Repor failureData.push({ onyxMethod: Onyx.METHOD.SET, key: `${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`, - value: transactionViolations, + value: transactionViolations ?? null, }); } @@ -5510,13 +5538,12 @@ function deleteMoneyRequest(transactionID: string, reportAction: OnyxTypes.Repor // STEP 7: Navigate the user depending on which page they are on and which resources were deleted if (iouReport && isSingleTransactionView && shouldDeleteTransactionThread && !shouldDeleteIOUReport) { // Pop the deleted report screen before navigating. This prevents navigating to the Concierge chat due to the missing report. - Navigation.goBack(ROUTES.REPORT_WITH_ID.getRoute(iouReport.reportID)); - return; + return ROUTES.REPORT_WITH_ID.getRoute(iouReport.reportID); } if (iouReport?.chatReportID && shouldDeleteIOUReport) { // Pop the deleted report screen before navigating. This prevents navigating to the Concierge chat due to the missing report. - Navigation.goBack(ROUTES.REPORT_WITH_ID.getRoute(iouReport.chatReportID)); + return ROUTES.REPORT_WITH_ID.getRoute(iouReport.chatReportID); } } @@ -5536,7 +5563,7 @@ function deleteTrackExpense(chatReportID: string, transactionID: string, reportA // STEP 7: Navigate the user depending on which page they are on and which resources were deleted if (isSingleTransactionView && shouldDeleteTransactionThread) { // Pop the deleted report screen before navigating. This prevents navigating to the Concierge chat due to the missing report. - Navigation.goBack(ROUTES.REPORT_WITH_ID.getRoute(chatReport?.reportID ?? '')); + return ROUTES.REPORT_WITH_ID.getRoute(chatReport?.reportID ?? ''); } } @@ -5899,7 +5926,7 @@ function getPayMoneyRequestParams( let currentNextStep = null; let optimisticNextStep = null; if (!isInvoiceReport) { - currentNextStep = allNextSteps[`${ONYXKEYS.COLLECTION.NEXT_STEP}${iouReport.reportID}`]; + currentNextStep = allNextSteps[`${ONYXKEYS.COLLECTION.NEXT_STEP}${iouReport.reportID}`] ?? null; optimisticNextStep = NextStepUtils.buildNextStep(iouReport, CONST.REPORT.STATUS_NUM.REIMBURSED, {isPaidWithExpensify: paymentMethodType === CONST.IOU.PAYMENT_TYPE.VBBA}); } @@ -6059,7 +6086,11 @@ function sendMoneyWithWallet(report: OnyxEntry, amount: number Report.notifyNewAction(params.chatReportID, managerID); } -function canApproveIOU(iouReport: OnyxEntry | EmptyObject, chatReport: OnyxEntry | EmptyObject, policy: OnyxEntry | EmptyObject) { +function canApproveIOU( + iouReport: OnyxTypes.OnyxInputOrEntry | EmptyObject, + chatReport: OnyxTypes.OnyxInputOrEntry | EmptyObject, + policy: OnyxTypes.OnyxInputOrEntry | EmptyObject, +) { if (isEmptyObject(chatReport)) { return false; } @@ -6086,7 +6117,11 @@ function canApproveIOU(iouReport: OnyxEntry | EmptyObject, cha return isCurrentUserManager && !isOpenExpenseReport && !isApproved && !iouSettled && !isArchivedReport; } -function canIOUBePaid(iouReport: OnyxEntry | EmptyObject, chatReport: OnyxEntry | EmptyObject, policy: OnyxEntry | EmptyObject) { +function canIOUBePaid( + iouReport: OnyxTypes.OnyxInputOrEntry | EmptyObject, + chatReport: OnyxTypes.OnyxInputOrEntry | EmptyObject, + policy: OnyxTypes.OnyxInputOrEntry | EmptyObject, +) { const isPolicyExpenseChat = ReportUtils.isPolicyExpenseChat(chatReport); const isChatReportArchived = ReportUtils.isArchivedRoom(chatReport); const iouSettled = ReportUtils.isSettled(iouReport?.reportID); @@ -6549,7 +6584,7 @@ function detachReceipt(transactionID: string) { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, value: { - ...transaction, + ...(transaction ?? null), errors: ErrorUtils.getMicroSecondOnyxError('iou.error.receiptDeleteFailureError'), }, }, diff --git a/src/libs/actions/MapboxToken.ts b/src/libs/actions/MapboxToken.ts index 3b98f79698ba..923f5590b2bd 100644 --- a/src/libs/actions/MapboxToken.ts +++ b/src/libs/actions/MapboxToken.ts @@ -9,18 +9,18 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {MapboxAccessToken, Network} from '@src/types/onyx'; -let authToken: string | null; +let authToken: string | undefined; Onyx.connect({ key: ONYXKEYS.SESSION, callback: (value) => { - authToken = value?.authToken ?? null; + authToken = value?.authToken; }, }); let connectionIDForToken: number | null; let connectionIDForNetwork: number | null; let appStateSubscription: NativeEventSubscription | null; -let currentToken: MapboxAccessToken | null; +let currentToken: MapboxAccessToken | undefined; let refreshTimeoutID: NodeJS.Timeout | undefined; let isCurrentlyFetchingToken = false; const REFRESH_INTERVAL = 1000 * 60 * 25; @@ -117,7 +117,7 @@ const init = () => { } if (!connectionIDForNetwork) { - let network: Network | null; + let network: Network | undefined; connectionIDForNetwork = Onyx.connect({ key: ONYXKEYS.NETWORK, callback: (value) => { diff --git a/src/libs/actions/Network.ts b/src/libs/actions/Network.ts index 9c88403b0e98..883e336d6c90 100644 --- a/src/libs/actions/Network.ts +++ b/src/libs/actions/Network.ts @@ -1,12 +1,24 @@ import Onyx from 'react-native-onyx'; +import Log from '@libs/Log'; import type {NetworkStatus} from '@libs/NetworkConnection'; import ONYXKEYS from '@src/ONYXKEYS'; -function setIsBackendReachable(isBackendReachable: boolean) { +function setIsBackendReachable(isBackendReachable: boolean, reason: string) { + if (isBackendReachable) { + Log.info(`[Network] Backend is reachable because: ${reason}`); + } else { + Log.info(`[Network] Backend is not reachable because: ${reason}`); + } Onyx.merge(ONYXKEYS.NETWORK, {isBackendReachable}); } -function setIsOffline(isOffline: boolean) { +function setIsOffline(isOffline: boolean, reason = '') { + if (reason) { + let textToLog = '[Network] Client is'; + textToLog += isOffline ? ' entering offline mode' : ' back online'; + textToLog += ` because: ${reason}`; + Log.info(textToLog); + } Onyx.merge(ONYXKEYS.NETWORK, {isOffline}); } diff --git a/src/libs/actions/OnyxUpdates.ts b/src/libs/actions/OnyxUpdates.ts index 04656f1adfec..341027f1db65 100644 --- a/src/libs/actions/OnyxUpdates.ts +++ b/src/libs/actions/OnyxUpdates.ts @@ -1,4 +1,4 @@ -import type {OnyxEntry, OnyxUpdate} from 'react-native-onyx'; +import type {OnyxUpdate} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; import type {Merge} from 'type-fest'; import Log from '@libs/Log'; @@ -13,7 +13,7 @@ import * as QueuedOnyxUpdates from './QueuedOnyxUpdates'; // This key needs to be separate from ONYXKEYS.ONYX_UPDATES_FROM_SERVER so that it can be updated without triggering the callback when the server IDs are updated. If that // callback were triggered it would lead to duplicate processing of server updates. -let lastUpdateIDAppliedToClient: OnyxEntry = 0; +let lastUpdateIDAppliedToClient: number | undefined = 0; Onyx.connect({ key: ONYXKEYS.ONYX_UPDATES_LAST_UPDATE_ID_APPLIED_TO_CLIENT, callback: (val) => (lastUpdateIDAppliedToClient = val), @@ -102,7 +102,7 @@ function apply({lastUpdateID, type, request, response, updates}: OnyxUpdatesFrom return Promise.resolve(); } - if (lastUpdateID && (lastUpdateIDAppliedToClient === null || Number(lastUpdateID) > lastUpdateIDAppliedToClient)) { + if (lastUpdateID && (lastUpdateIDAppliedToClient === undefined || Number(lastUpdateID) > lastUpdateIDAppliedToClient)) { Onyx.merge(ONYXKEYS.ONYX_UPDATES_LAST_UPDATE_ID_APPLIED_TO_CLIENT, Number(lastUpdateID)); } if (type === CONST.ONYX_UPDATE_TYPES.HTTPS && request && response) { @@ -151,4 +151,4 @@ function doesClientNeedToBeUpdated(previousUpdateID = 0, clientLastUpdateID = 0) } // eslint-disable-next-line import/prefer-default-export -export {saveUpdateInformation, doesClientNeedToBeUpdated, apply}; +export {apply, doesClientNeedToBeUpdated, saveUpdateInformation}; diff --git a/src/libs/actions/PersonalDetails.ts b/src/libs/actions/PersonalDetails.ts index b9cea5c9447c..daeb4ad58802 100644 --- a/src/libs/actions/PersonalDetails.ts +++ b/src/libs/actions/PersonalDetails.ts @@ -37,7 +37,7 @@ Onyx.connect({ }, }); -let allPersonalDetails: OnyxEntry = null; +let allPersonalDetails: OnyxEntry; Onyx.connect({ key: ONYXKEYS.PERSONAL_DETAILS_LIST, callback: (val) => (allPersonalDetails = val), diff --git a/src/libs/actions/Policy/Category.ts b/src/libs/actions/Policy/Category.ts index 294712666bdf..5db8be5b8a5a 100644 --- a/src/libs/actions/Policy/Category.ts +++ b/src/libs/actions/Policy/Category.ts @@ -279,7 +279,6 @@ function renamePolicyCategory(policyID: string, policyCategory: {oldName: string [policyCategory.newName]: { ...policyCategoryToUpdate, name: policyCategory.newName, - unencodedName: decodeURIComponent(policyCategory.newName), pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, pendingFields: { name: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, @@ -298,7 +297,6 @@ function renamePolicyCategory(policyID: string, policyCategory: {oldName: string [policyCategory.newName]: { ...policyCategoryToUpdate, name: policyCategory.newName, - unencodedName: decodeURIComponent(policyCategory.newName), errors: null, pendingAction: null, pendingFields: { @@ -317,7 +315,6 @@ function renamePolicyCategory(policyID: string, policyCategory: {oldName: string [policyCategory.oldName]: { ...policyCategoryToUpdate, name: policyCategory.oldName, - unencodedName: decodeURIComponent(policyCategory.oldName), errors: ErrorUtils.getMicroSecondOnyxError('workspace.categories.updateFailureMessage'), pendingAction: null, }, diff --git a/src/libs/actions/Policy/Member.ts b/src/libs/actions/Policy/Member.ts index ee2f02b68252..fc28a01b043c 100644 --- a/src/libs/actions/Policy/Member.ts +++ b/src/libs/actions/Policy/Member.ts @@ -1,5 +1,5 @@ import {ExpensiMark} from 'expensify-common'; -import type {NullishDeep, OnyxCollection, OnyxEntry, OnyxUpdate} from 'react-native-onyx'; +import type {NullishDeep, OnyxCollection, OnyxCollectionInputValue, OnyxEntry, OnyxUpdate} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; import * as API from '@libs/API'; import type { @@ -232,9 +232,9 @@ function removeMembers(accountIDs: number[], policyID: string) { const announceRoomMembers = removeOptimisticAnnounceRoomMembers(policy.id, policy.name, accountIDs); - const optimisticMembersState: OnyxCollection = {}; - const successMembersState: OnyxCollection = {}; - const failureMembersState: OnyxCollection = {}; + const optimisticMembersState: OnyxCollectionInputValue = {}; + const successMembersState: OnyxCollectionInputValue = {}; + const failureMembersState: OnyxCollectionInputValue = {}; emailList.forEach((email) => { optimisticMembersState[email] = {pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE}; successMembersState[email] = null; @@ -530,9 +530,9 @@ function addMembersToWorkspace(invitedEmailsToAccountIDs: InvitedEmailsToAccount // create onyx data for policy expense chats for each new member const membersChats = createPolicyExpenseChats(policyID, invitedEmailsToAccountIDs); - const optimisticMembersState: OnyxCollection = {}; - const successMembersState: OnyxCollection = {}; - const failureMembersState: OnyxCollection = {}; + const optimisticMembersState: OnyxCollectionInputValue = {}; + const successMembersState: OnyxCollectionInputValue = {}; + const failureMembersState: OnyxCollectionInputValue = {}; logins.forEach((email) => { optimisticMembersState[email] = {pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, role: CONST.POLICY.ROLE.USER}; successMembersState[email] = {pendingAction: null}; diff --git a/src/libs/actions/Policy/Policy.ts b/src/libs/actions/Policy/Policy.ts index 0e168f973078..8f82ca5881fc 100644 --- a/src/libs/actions/Policy/Policy.ts +++ b/src/libs/actions/Policy/Policy.ts @@ -120,14 +120,14 @@ Onyx.connect({ }, }); -let allReports: OnyxCollection = null; +let allReports: OnyxCollection; Onyx.connect({ key: ONYXKEYS.COLLECTION.REPORT, waitForCollectionCallback: true, callback: (value) => (allReports = value), }); -let lastAccessedWorkspacePolicyID: OnyxEntry = null; +let lastAccessedWorkspacePolicyID: OnyxEntry; Onyx.connect({ key: ONYXKEYS.LAST_ACCESSED_WORKSPACE_POLICY_ID, callback: (value) => (lastAccessedWorkspacePolicyID = value), @@ -159,7 +159,7 @@ Onyx.connect({ * Stores in Onyx the policy ID of the last workspace that was accessed by the user */ function updateLastAccessedWorkspace(policyID: OnyxEntry) { - Onyx.set(ONYXKEYS.LAST_ACCESSED_WORKSPACE_POLICY_ID, policyID); + Onyx.set(ONYXKEYS.LAST_ACCESSED_WORKSPACE_POLICY_ID, policyID ?? null); } /** @@ -334,7 +334,7 @@ function deleteWorkspace(policyID: string, policyName: string) { // Reset the lastAccessedWorkspacePolicyID if (policyID === lastAccessedWorkspacePolicyID) { - updateLastAccessedWorkspace(null); + updateLastAccessedWorkspace(undefined); } } @@ -1452,6 +1452,7 @@ function createDraftInitialWorkspace(policyOwnerEmail = '', policyName = '', pol customUnits, makeMeAdmin, autoReporting: true, + autoReportingFrequency: CONST.POLICY.AUTO_REPORTING_FREQUENCIES.INSTANT, employeeList: { [sessionEmail]: { role: CONST.POLICY.ROLE.ADMIN, @@ -1574,6 +1575,7 @@ function buildPolicyData(policyOwnerEmail = '', makeMeAdmin = false, policyName outputCurrency, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, autoReporting: true, + autoReportingFrequency: CONST.POLICY.AUTO_REPORTING_FREQUENCIES.INSTANT, approvalMode: CONST.POLICY.APPROVAL_MODE.OPTIONAL, harvesting: { enabled: true, @@ -1837,6 +1839,7 @@ function createDraftWorkspace(policyOwnerEmail = '', makeMeAdmin = false, policy outputCurrency, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, autoReporting: true, + autoReportingFrequency: CONST.POLICY.AUTO_REPORTING_FREQUENCIES.INSTANT, approvalMode: CONST.POLICY.APPROVAL_MODE.OPTIONAL, harvesting: { enabled: true, @@ -2109,6 +2112,7 @@ function createWorkspaceFromIOUPayment(iouReport: Report | EmptyObject): string outputCurrency: CONST.CURRENCY.USD, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, autoReporting: true, + autoReportingFrequency: CONST.POLICY.AUTO_REPORTING_FREQUENCIES.INSTANT, approvalMode: CONST.POLICY.APPROVAL_MODE.OPTIONAL, harvesting: { enabled: true, diff --git a/src/libs/actions/Policy/Tag.ts b/src/libs/actions/Policy/Tag.ts index 85080d741011..a8607ca8f5f9 100644 --- a/src/libs/actions/Policy/Tag.ts +++ b/src/libs/actions/Policy/Tag.ts @@ -1,7 +1,7 @@ import type {NullishDeep, OnyxCollection, OnyxEntry} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; import * as API from '@libs/API'; -import type {EnablePolicyTagsParams, OpenPolicyTagsPageParams, RenamePolicyTaglistParams, RenamePolicyTagsParams, SetPolicyTagsEnabled} from '@libs/API/parameters'; +import type {EnablePolicyTagsParams, OpenPolicyTagsPageParams, RenamePolicyTaglistParams, RenamePolicyTagsParams, SetPolicyTagsEnabled, SetPolicyTagsRequired} from '@libs/API/parameters'; import {READ_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; import * as ErrorUtils from '@libs/ErrorUtils'; import getIsNarrowLayout from '@libs/getIsNarrowLayout'; @@ -161,6 +161,7 @@ function createPolicyTag(policyID: string, tagName: string) { tags: { [newTagName]: { errors: ErrorUtils.getMicroSecondOnyxError('workspace.tags.genericFailureMessage'), + pendingAction: null, }, }, }, @@ -329,8 +330,8 @@ function deletePolicyTags(policyID: string, tagsToDelete: string[]) { API.write(WRITE_COMMANDS.DELETE_POLICY_TAGS, parameters, onyxData); } -function clearPolicyTagErrors(policyID: string, tagName: string) { - const tagListName = Object.keys(allPolicyTags?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`] ?? {})[0]; +function clearPolicyTagErrors(policyID: string, tagName: string, tagListIndex: number) { + const tagListName = Object.keys(allPolicyTags?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`] ?? {})[tagListIndex]; const tag = allPolicyTags?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`]?.[tagListName].tags?.[tagName]; if (!tag) { return; @@ -359,10 +360,25 @@ function clearPolicyTagErrors(policyID: string, tagName: string) { }); } +function clearPolicyTagListError(policyID: string, tagListIndex: number, errorField: string) { + const policyTag = PolicyUtils.getTagLists(allPolicyTags?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`] ?? {})?.[tagListIndex] ?? {}; + + if (!policyTag.name) { + return; + } + + Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`, { + [policyTag.name]: { + errorFields: { + [errorField]: null, + }, + }, + }); +} + function renamePolicyTag(policyID: string, policyTag: {oldName: string; newName: string}, tagListIndex: number) { const tagList = PolicyUtils.getTagLists(allPolicyTags?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`] ?? {})?.[tagListIndex] ?? {}; const tag = tagList.tags?.[policyTag.oldName]; - const oldTagName = policyTag.oldName; const newTagName = PolicyUtils.escapeTagName(policyTag.newName); const onyxData: OnyxData = { @@ -611,15 +627,75 @@ function setPolicyRequiresTag(policyID: string, requiresTag: boolean) { API.write(WRITE_COMMANDS.SET_POLICY_REQUIRES_TAG, parameters, onyxData); } +function setPolicyTagsRequired(policyID: string, requiresTag: boolean, tagListIndex: number) { + const policyTag = PolicyUtils.getTagLists(allPolicyTags?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`] ?? {})?.[tagListIndex] ?? {}; + + if (!policyTag.name) { + return; + } + + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`, + value: { + [policyTag.name]: { + required: requiresTag, + pendingFields: {required: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}, + errorFields: {required: null}, + }, + }, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`, + value: { + [policyTag.name]: { + pendingFields: {required: null}, + }, + }, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`, + value: { + [policyTag.name]: { + required: policyTag.required, + pendingFields: {required: null}, + errorFields: { + required: ErrorUtils.getMicroSecondOnyxError('workspace.tags.genericFailureMessage'), + }, + }, + }, + }, + ], + }; + + const parameters: SetPolicyTagsRequired = { + policyID, + tagListIndex, + requireTagList: requiresTag, + }; + + API.write(WRITE_COMMANDS.SET_POLICY_TAGS_REQUIRED, parameters, onyxData); +} + export { openPolicyTagsPage, buildOptimisticPolicyRecentlyUsedTags, setPolicyRequiresTag, + setPolicyTagsRequired, renamePolicyTaglist, enablePolicyTags, createPolicyTag, renamePolicyTag, clearPolicyTagErrors, + clearPolicyTagListError, deletePolicyTags, setWorkspaceTagEnabled, }; diff --git a/src/libs/actions/PriorityMode.ts b/src/libs/actions/PriorityMode.ts index 77347967b6cd..3679df575e9c 100644 --- a/src/libs/actions/PriorityMode.ts +++ b/src/libs/actions/PriorityMode.ts @@ -21,7 +21,7 @@ let isReadyPromise = new Promise((resolve) => { resolveIsReadyPromise = resolve; }); -let currentUserAccountID: number | undefined | null; +let currentUserAccountID: number | undefined; Onyx.connect({ key: ONYXKEYS.SESSION, callback: (val) => { @@ -70,11 +70,11 @@ Onyx.connect({ }, }); -let hasTriedFocusMode: boolean | undefined | null; +let hasTriedFocusMode: boolean | null | undefined; Onyx.connect({ key: ONYXKEYS.NVP_TRY_FOCUS_MODE, callback: (val) => { - hasTriedFocusMode = val; + hasTriedFocusMode = val ?? null; // eslint-disable-next-line @typescript-eslint/no-use-before-define checkRequiredData(); diff --git a/src/libs/actions/PushNotification.ts b/src/libs/actions/PushNotification.ts index bc4d4eb05c5a..d4caf7925d4c 100644 --- a/src/libs/actions/PushNotification.ts +++ b/src/libs/actions/PushNotification.ts @@ -8,7 +8,7 @@ let isUserOptedInToPushNotifications = false; Onyx.connect({ key: ONYXKEYS.PUSH_NOTIFICATIONS_ENABLED, callback: (value) => { - if (value === null) { + if (value === undefined) { return; } isUserOptedInToPushNotifications = value; @@ -35,7 +35,7 @@ function setPushNotificationOptInStatus(isOptingIn: boolean) { value: isUserOptedInToPushNotifications, }, ]; - API.write(commandName, {deviceID}, {optimisticData, failureData}); + API.write(commandName, {deviceID: deviceID ?? null}, {optimisticData, failureData}); }); } diff --git a/src/libs/actions/ReimbursementAccount/resetFreePlanBankAccount.ts b/src/libs/actions/ReimbursementAccount/resetFreePlanBankAccount.ts index 3ad8b9ffe599..f8d887fec47a 100644 --- a/src/libs/actions/ReimbursementAccount/resetFreePlanBankAccount.ts +++ b/src/libs/actions/ReimbursementAccount/resetFreePlanBankAccount.ts @@ -76,7 +76,6 @@ function resetFreePlanBankAccount(bankAccountID: number | undefined, session: On key: ONYXKEYS.PLAID_LINK_TOKEN, value: '', }, - // @ts-expect-error: ONYXKEYS.REIMBURSEMENT_ACCOUNT is conflicting with ONYXKEYS.FORMS.REIMBURSEMENT { onyxMethod: Onyx.METHOD.SET, key: ONYXKEYS.REIMBURSEMENT_ACCOUNT, diff --git a/src/libs/actions/ReimbursementAccount/store.ts b/src/libs/actions/ReimbursementAccount/store.ts index c82843020f66..644fcbdf4def 100644 --- a/src/libs/actions/ReimbursementAccount/store.ts +++ b/src/libs/actions/ReimbursementAccount/store.ts @@ -4,7 +4,7 @@ import BankAccount from '@libs/models/BankAccount'; import ONYXKEYS from '@src/ONYXKEYS'; import type * as OnyxTypes from '@src/types/onyx'; -let bankAccountList: OnyxEntry = null; +let bankAccountList: OnyxEntry; Onyx.connect({ key: ONYXKEYS.BANK_ACCOUNT_LIST, callback: (val) => { diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index 7326b70edb61..3adf48046936 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -7,6 +7,7 @@ import Onyx from 'react-native-onyx'; import type {PartialDeep, ValueOf} from 'type-fest'; import type {Emoji} from '@assets/emojis/types'; import type {FileObject} from '@components/AttachmentModal'; +import AccountUtils from '@libs/AccountUtils'; import * as ActiveClientManager from '@libs/ActiveClientManager'; import * as API from '@libs/API'; import type { @@ -82,7 +83,6 @@ import INPUT_IDS from '@src/types/form/NewRoomForm'; import type { InvitedEmailsToAccountIDs, NewGroupChatDraft, - PersonalDetails, PersonalDetailsList, PolicyReportField, QuickAction, @@ -469,7 +469,7 @@ function addActions(reportID: string, text = '', file?: FileObject) { const lastCommentText = ReportUtils.formatReportLastMessageText(lastComment?.text ?? ''); const optimisticReport: Partial = { - lastVisibleActionCreated: currentTime, + lastVisibleActionCreated: lastAction?.created, lastMessageTranslationKey: lastComment?.translationKey ?? '', lastMessageText: lastCommentText, lastMessageHtml: lastCommentText, @@ -862,8 +862,8 @@ function openReport( }); // Add optimistic personal details for new participants - const optimisticPersonalDetails: OnyxCollection = {}; - const settledPersonalDetails: OnyxCollection = {}; + const optimisticPersonalDetails: OnyxEntry = {}; + const settledPersonalDetails: OnyxEntry = {}; const redundantParticipants: Record = {}; const participantAccountIDs = PersonalDetailsUtils.getAccountIDsByLogins(participantLoginList); participantLoginList.forEach((login, index) => { @@ -1019,7 +1019,6 @@ function navigateToAndOpenReportWithAccountIDs(participantAccountIDs: number[]) */ function navigateToAndOpenChildReport(childReportID = '0', parentReportAction: Partial = {}, parentReportID = '0') { if (childReportID !== '0') { - openReport(childReportID); Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(childReportID)); } else { const participantAccountIDs = [...new Set([currentUserAccountID, Number(parentReportAction.actorAccountID)])]; @@ -2008,6 +2007,17 @@ function navigateToConciergeChat(shouldDismissModal = false, checkIfCurrentPageA } } +/** + * Navigates to the 1:1 system chat + */ +function navigateToSystemChat() { + const systemChatReport = ReportUtils.getSystemChat(); + + if (systemChatReport?.reportID) { + Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(systemChatReport.reportID)); + } +} + /** Add a policy report (workspace room) optimistically and navigate to it. */ function addPolicyReport(policyReport: ReportUtils.OptimisticChatReport) { const createdReportAction = ReportUtils.buildOptimisticCreatedReportAction(CONST.POLICY.OWNER_EMAIL_FAKE); @@ -3115,7 +3125,9 @@ function completeOnboarding( }, adminsChatReportID?: string, ) { - const targetEmail = CONST.EMAIL.CONCIERGE; + const isAccountIDOdd = AccountUtils.isAccountIDOddNumber(currentUserAccountID ?? 0); + const targetEmail = isAccountIDOdd ? CONST.EMAIL.NOTIFICATIONS : CONST.EMAIL.CONCIERGE; + const actorAccountID = PersonalDetailsUtils.getAccountIDsByLogins([targetEmail])[0]; const targetChatReport = ReportUtils.getChatByParticipants([actorAccountID, currentUserAccountID]); const {reportID: targetChatReportID = '', policyID: targetChatPolicyID = ''} = targetChatReport ?? {}; @@ -3286,6 +3298,50 @@ function completeOnboarding( return acc; }, []); + const tasksForSuccessData = tasksData.reduce((acc, {currentTask, taskCreatedAction, taskReportAction, completedTaskReportAction}) => { + acc.push( + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${targetChatReportID}`, + value: { + [taskReportAction.reportAction.reportActionID]: {pendingAction: null}, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${currentTask.reportID}`, + value: { + pendingFields: { + createChat: null, + reportName: null, + description: null, + managerID: null, + }, + isOptimisticReport: false, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${currentTask.reportID}`, + value: { + [taskCreatedAction.reportActionID]: {pendingAction: null}, + }, + }, + ); + + if (completedTaskReportAction) { + acc.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${currentTask.reportID}`, + value: { + [completedTaskReportAction.reportActionID]: {pendingAction: null}, + }, + }); + } + + return acc; + }, []); + const optimisticData: OnyxUpdate[] = [ ...tasksForOptimisticData, { @@ -3310,6 +3366,7 @@ function completeOnboarding( }, ]; const successData: OnyxUpdate[] = [ + ...tasksForSuccessData, { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${targetChatReportID}`, @@ -3404,6 +3461,7 @@ function completeOnboarding( engagementChoice, firstName, lastName, + actorAccountID, guidedSetupData: JSON.stringify(guidedSetupData), }; @@ -3683,6 +3741,7 @@ export { saveReportActionDraft, deleteReportComment, navigateToConciergeChat, + navigateToSystemChat, addPolicyReport, deleteReport, navigateToConciergeChatAndDeleteReport, diff --git a/src/libs/actions/ReportActions.ts b/src/libs/actions/ReportActions.ts index 217dd0b12100..4fc72bae1e7d 100644 --- a/src/libs/actions/ReportActions.ts +++ b/src/libs/actions/ReportActions.ts @@ -71,11 +71,11 @@ Onyx.connect({ }); /** - * + * ignore: `undefined` means we want to check both parent and children report actions ignore: `parent` or `child` means we want to ignore checking parent or child report actions because they've been previously checked */ -function clearAllRelatedReportActionErrors(reportID: string, reportAction: ReportAction | null, ignore?: IgnoreDirection, keys?: string[]) { +function clearAllRelatedReportActionErrors(reportID: string, reportAction: ReportAction | null | undefined, ignore?: IgnoreDirection, keys?: string[]) { const errorKeys = keys ?? Object.keys(reportAction?.errors ?? {}); if (!reportAction || errorKeys.length === 0) { return; diff --git a/src/libs/actions/Session/index.ts b/src/libs/actions/Session/index.ts index 558f4c9e027a..85baad7a0f0e 100644 --- a/src/libs/actions/Session/index.ts +++ b/src/libs/actions/Session/index.ts @@ -92,7 +92,7 @@ Onyx.connect({ let preferredLocale: ValueOf | null = null; Onyx.connect({ key: ONYXKEYS.NVP_PREFERRED_LOCALE, - callback: (val) => (preferredLocale = val), + callback: (val) => (preferredLocale = val ?? null), }); function isSupportAuthToken(): boolean { @@ -170,7 +170,7 @@ function signOut() { Log.info('Flushing logs before signing out', true, {}, true); const params = { // Send current authToken because we will immediately clear it once triggering this command - authToken: NetworkStore.getAuthToken(), + authToken: NetworkStore.getAuthToken() ?? null, partnerUserID: credentials?.autoGeneratedLogin ?? '', partnerName: CONFIG.EXPENSIFY.PARTNER_NAME, partnerPassword: CONFIG.EXPENSIFY.PARTNER_PASSWORD, diff --git a/src/libs/actions/Subscription.ts b/src/libs/actions/Subscription.ts index c7732575aaa6..c5623d63db57 100644 --- a/src/libs/actions/Subscription.ts +++ b/src/libs/actions/Subscription.ts @@ -1,5 +1,11 @@ +import type {OnyxUpdate} from 'react-native-onyx'; +import Onyx from 'react-native-onyx'; import * as API from '@libs/API'; -import {READ_COMMANDS} from '@libs/API/types'; +import type {UpdateSubscriptionTypeParams} from '@libs/API/parameters'; +import {READ_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; +import type {SubscriptionType} from '@src/CONST'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; /** * Fetches data when the user opens the SubscriptionSettingsPage @@ -8,7 +14,51 @@ function openSubscriptionPage() { API.read(READ_COMMANDS.OPEN_SUBSCRIPTION_PAGE, null); } -export { - // eslint-disable-next-line import/prefer-default-export - openSubscriptionPage, -}; +function updateSubscriptionType(type: SubscriptionType) { + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.NVP_PRIVATE_SUBSCRIPTION, + value: { + type, + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + errors: null, + }, + }, + ]; + + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.NVP_PRIVATE_SUBSCRIPTION, + value: { + type, + pendingAction: null, + errors: null, + }, + }, + ]; + + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.NVP_PRIVATE_SUBSCRIPTION, + value: { + type: type === CONST.SUBSCRIPTION.TYPE.ANNUAL ? CONST.SUBSCRIPTION.TYPE.PAYPERUSE : CONST.SUBSCRIPTION.TYPE.ANNUAL, + pendingAction: null, + }, + }, + ]; + + const parameters: UpdateSubscriptionTypeParams = { + type, + }; + + API.write(WRITE_COMMANDS.UPDATE_SUBSCRIPTION_TYPE, parameters, { + optimisticData, + successData, + failureData, + }); +} + +export {openSubscriptionPage, updateSubscriptionType}; diff --git a/src/libs/actions/Task.ts b/src/libs/actions/Task.ts index 55d898d3d4f3..7bff7bdea887 100644 --- a/src/libs/actions/Task.ts +++ b/src/libs/actions/Task.ts @@ -117,7 +117,7 @@ function createTaskAndNavigate( description: string, assigneeEmail: string, assigneeAccountID = 0, - assigneeChatReport: OnyxEntry = null, + assigneeChatReport?: OnyxEntry, policyID: string = CONST.POLICY.OWNER_EMAIL_FAKE, ) { const optimisticTaskReport = ReportUtils.buildOptimisticTaskReport(currentUserAccountID, assigneeAccountID, parentReportID, title, description, policyID); @@ -131,10 +131,10 @@ function createTaskAndNavigate( const optimisticAddCommentReport = ReportUtils.buildOptimisticTaskCommentReportAction(taskReportID, title, assigneeAccountID, `task for ${title}`, parentReportID); optimisticTaskReport.parentReportActionID = optimisticAddCommentReport.reportAction.reportActionID; - const currentTime = DateUtils.getDBTime(); + const currentTime = DateUtils.getDBTimeWithSkew(); const lastCommentText = ReportUtils.formatReportLastMessageText(optimisticAddCommentReport?.reportAction?.message?.[0]?.text ?? ''); const optimisticParentReport = { - lastVisibleActionCreated: currentTime, + lastVisibleActionCreated: optimisticAddCommentReport.reportAction.created, lastMessageText: lastCommentText, lastActorAccountID: currentUserAccountID, lastReadTime: currentTime, @@ -508,13 +508,7 @@ function editTask(report: OnyxTypes.Report, {title, description}: OnyxTypes.Task Report.notifyNewAction(report.reportID, currentUserAccountID); } -function editTaskAssignee( - report: OnyxTypes.Report, - ownerAccountID: number, - assigneeEmail: string, - assigneeAccountID: number | null = 0, - assigneeChatReport: OnyxEntry = null, -) { +function editTaskAssignee(report: OnyxTypes.Report, ownerAccountID: number, assigneeEmail: string, assigneeAccountID: number | null = 0, assigneeChatReport?: OnyxEntry) { // Create the EditedReportAction on the task const editTaskReportAction = ReportUtils.buildOptimisticChangedTaskAssigneeReportAction(assigneeAccountID ?? 0); const reportName = report.reportName?.trim(); @@ -770,7 +764,7 @@ function getAssignee(assigneeAccountID: number, personalDetails: OnyxEntry, personalDetails: OnyxEntry): ShareDestination { - const report = reports?.[`report_${reportID}`] ?? null; + const report = reports?.[`report_${reportID}`]; const isOneOnOneChat = ReportUtils.isOneOnOneChat(report); diff --git a/src/libs/actions/TaxRate.ts b/src/libs/actions/TaxRate.ts index f8425cd0c40c..99ce116d1361 100644 --- a/src/libs/actions/TaxRate.ts +++ b/src/libs/actions/TaxRate.ts @@ -199,6 +199,7 @@ function setPolicyTaxesEnabled(policyID: string, taxesIDsToUpdate: string[], isE acc[taxID] = { isDisabled: !isEnabled, pendingFields: {isDisabled: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}, + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, errorFields: {isDisabled: null}, }; return acc; @@ -214,7 +215,7 @@ function setPolicyTaxesEnabled(policyID: string, taxesIDsToUpdate: string[], isE value: { taxRates: { taxes: taxesIDsToUpdate.reduce((acc, taxID) => { - acc[taxID] = {pendingFields: {isDisabled: null}, errorFields: {isDisabled: null}}; + acc[taxID] = {pendingFields: {isDisabled: null}, errorFields: {isDisabled: null}, pendingAction: null}; return acc; }, {}), }, @@ -231,6 +232,7 @@ function setPolicyTaxesEnabled(policyID: string, taxesIDsToUpdate: string[], isE acc[taxID] = { isDisabled: !!originalTaxes[taxID].isDisabled, pendingFields: {isDisabled: null}, + pendingAction: null, errorFields: {isDisabled: ErrorUtils.getMicroSecondOnyxError('workspace.taxes.error.updateFailureMessage')}, }; return acc; @@ -346,6 +348,7 @@ function updatePolicyTaxValue(policyID: string, taxID: string, taxValue: number) [taxID]: { value: stringTaxValue, pendingFields: {value: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}, + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, errorFields: {value: null}, }, }, @@ -360,7 +363,7 @@ function updatePolicyTaxValue(policyID: string, taxID: string, taxValue: number) value: { taxRates: { taxes: { - [taxID]: {pendingFields: {value: null}, errorFields: {value: null}}, + [taxID]: {pendingFields: {value: null}, pendingAction: null, errorFields: {value: null}}, }, }, }, @@ -376,6 +379,7 @@ function updatePolicyTaxValue(policyID: string, taxID: string, taxValue: number) [taxID]: { value: originalTaxRate.value, pendingFields: {value: null}, + pendingAction: null, errorFields: {value: ErrorUtils.getMicroSecondOnyxError('workspace.taxes.error.updateFailureMessage')}, }, }, @@ -408,6 +412,7 @@ function renamePolicyTax(policyID: string, taxID: string, newName: string) { [taxID]: { name: newName, pendingFields: {name: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}, + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, errorFields: {name: null}, }, }, @@ -422,7 +427,7 @@ function renamePolicyTax(policyID: string, taxID: string, newName: string) { value: { taxRates: { taxes: { - [taxID]: {pendingFields: {name: null}, errorFields: {name: null}}, + [taxID]: {pendingFields: {name: null}, pendingAction: null, errorFields: {name: null}}, }, }, }, @@ -438,6 +443,7 @@ function renamePolicyTax(policyID: string, taxID: string, newName: string) { [taxID]: { name: originalTaxRate.name, pendingFields: {name: null}, + pendingAction: null, errorFields: {name: ErrorUtils.getMicroSecondOnyxError('workspace.taxes.error.updateFailureMessage')}, }, }, diff --git a/src/libs/actions/TransactionEdit.ts b/src/libs/actions/TransactionEdit.ts index 970b34591103..98423ca48d0a 100644 --- a/src/libs/actions/TransactionEdit.ts +++ b/src/libs/actions/TransactionEdit.ts @@ -33,7 +33,7 @@ function restoreOriginalTransactionFromBackup(transactionID: string, isDraft: bo Onyx.disconnect(connectionID); // Use set to completely overwrite the original transaction - Onyx.set(`${isDraft ? ONYXKEYS.COLLECTION.TRANSACTION_DRAFT : ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, backupTransaction); + Onyx.set(`${isDraft ? ONYXKEYS.COLLECTION.TRANSACTION_DRAFT : ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, backupTransaction ?? null); removeBackupTransaction(transactionID); }, }); diff --git a/src/libs/actions/User.ts b/src/libs/actions/User.ts index 8c9d4391bb46..c70eb73ec2a5 100644 --- a/src/libs/actions/User.ts +++ b/src/libs/actions/User.ts @@ -69,7 +69,7 @@ Onyx.connect({ return; } - myPersonalDetails = value[currentUserAccountID]; + myPersonalDetails = value[currentUserAccountID] ?? {}; }, }); diff --git a/src/libs/actions/Wallet.ts b/src/libs/actions/Wallet.ts index 3dd3f01c0703..c12c44c1ed74 100644 --- a/src/libs/actions/Wallet.ts +++ b/src/libs/actions/Wallet.ts @@ -218,7 +218,7 @@ function openEnablePaymentsPage() { API.read(READ_COMMANDS.OPEN_ENABLE_PAYMENTS_PAGE, {}); } -function updateCurrentStep(currentStep: ValueOf) { +function updateCurrentStep(currentStep: ValueOf | null) { Onyx.merge(ONYXKEYS.USER_WALLET, {currentStep}); } diff --git a/src/libs/actions/Welcome.ts b/src/libs/actions/Welcome.ts index 2dbe47608ab9..3f70dc0d962d 100644 --- a/src/libs/actions/Welcome.ts +++ b/src/libs/actions/Welcome.ts @@ -80,7 +80,7 @@ Onyx.connect({ key: ONYXKEYS.NVP_ONBOARDING, initWithStoredValues: false, callback: (value) => { - if (value === null) { + if (value === undefined) { return; } diff --git a/src/libs/migrations/KeyReportActionsDraftByReportActionID.ts b/src/libs/migrations/KeyReportActionsDraftByReportActionID.ts index 193fdfe5f1eb..9ec85cf35745 100644 --- a/src/libs/migrations/KeyReportActionsDraftByReportActionID.ts +++ b/src/libs/migrations/KeyReportActionsDraftByReportActionID.ts @@ -1,4 +1,4 @@ -import type {OnyxEntry} from 'react-native-onyx'; +import type {OnyxInputValue} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; import Log from '@libs/Log'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -27,7 +27,7 @@ export default function () { return resolve(); } - const newReportActionsDrafts: Record> = {}; + const newReportActionsDrafts: Record> = {}; Object.entries(allReportActionsDrafts).forEach(([onyxKey, reportActionDraft]) => { if (typeof reportActionDraft !== 'string') { return; diff --git a/src/libs/migrations/NVPMigration.ts b/src/libs/migrations/NVPMigration.ts index dcab130186d3..7af3bc927c6b 100644 --- a/src/libs/migrations/NVPMigration.ts +++ b/src/libs/migrations/NVPMigration.ts @@ -32,7 +32,7 @@ export default function () { key: oldKey as OnyxKey, callback: (value) => { Onyx.disconnect(connectionID); - if (value === null) { + if (value === undefined) { resolveWhenDone(); return; } diff --git a/src/libs/migrations/PronounsMigration.ts b/src/libs/migrations/PronounsMigration.ts index 5fe911ae7f98..81744fde298c 100644 --- a/src/libs/migrations/PronounsMigration.ts +++ b/src/libs/migrations/PronounsMigration.ts @@ -17,7 +17,7 @@ function getCurrentUserAccountIDFromOnyx(): Promise { }); } -function getCurrentUserPersonalDetailsFromOnyx(currentUserAccountID: number): Promise> { +function getCurrentUserPersonalDetailsFromOnyx(currentUserAccountID: number): Promise> | null> { return new Promise((resolve) => { const connectionID = Onyx.connect({ key: ONYXKEYS.PERSONAL_DETAILS_LIST, @@ -35,7 +35,7 @@ function getCurrentUserPersonalDetailsFromOnyx(currentUserAccountID: number): Pr export default function (): Promise { return getCurrentUserAccountIDFromOnyx() .then(getCurrentUserPersonalDetailsFromOnyx) - .then((currentUserPersonalDetails: OnyxEntry) => { + .then((currentUserPersonalDetails) => { if (!currentUserPersonalDetails) { return; } diff --git a/src/libs/migrations/RemoveEmptyReportActionsDrafts.ts b/src/libs/migrations/RemoveEmptyReportActionsDrafts.ts index 48493a82d641..743e9587479b 100644 --- a/src/libs/migrations/RemoveEmptyReportActionsDrafts.ts +++ b/src/libs/migrations/RemoveEmptyReportActionsDrafts.ts @@ -1,5 +1,5 @@ import _ from 'lodash'; -import type {OnyxEntry} from 'react-native-onyx'; +import type {OnyxInputValue} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; import Log from '@libs/Log'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -24,7 +24,7 @@ export default function (): Promise { return resolve(); } - const newReportActionsDrafts: Record> = {}; + const newReportActionsDrafts: Record> = {}; Object.entries(allReportActionsDrafts).forEach(([onyxKey, reportActionDrafts]) => { const newReportActionsDraftsForReport: Record = {}; diff --git a/src/libs/migrations/RenameReceiptFilename.ts b/src/libs/migrations/RenameReceiptFilename.ts index bb873ee0ea21..f01676595dd7 100644 --- a/src/libs/migrations/RenameReceiptFilename.ts +++ b/src/libs/migrations/RenameReceiptFilename.ts @@ -1,5 +1,5 @@ import Onyx from 'react-native-onyx'; -import type {NullishDeep, OnyxCollection} from 'react-native-onyx'; +import type {NullishDeep, OnyxCollection, OnyxEntry} from 'react-native-onyx'; import Log from '@libs/Log'; import ONYXKEYS from '@src/ONYXKEYS'; import type Transaction from '@src/types/onyx/Transaction'; @@ -24,7 +24,7 @@ export default function () { return resolve(); } - const transactionsWithReceipt: Array = Object.values(transactions).filter((transaction) => transaction?.receiptFilename); + const transactionsWithReceipt: Array> = Object.values(transactions).filter((transaction) => transaction?.receiptFilename); if (!transactionsWithReceipt?.length) { Log.info('[Migrate Onyx] Skipped migration RenameReceiptFilename because there were no transactions with the receiptFilename property'); return resolve(); diff --git a/src/libs/migrations/TransactionBackupsToCollection.ts b/src/libs/migrations/TransactionBackupsToCollection.ts index a7167492007a..53fa7e855e72 100644 --- a/src/libs/migrations/TransactionBackupsToCollection.ts +++ b/src/libs/migrations/TransactionBackupsToCollection.ts @@ -1,4 +1,4 @@ -import type {OnyxCollection} from 'react-native-onyx'; +import type {OnyxCollection, OnyxCollectionInputValue, OnyxEntry} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; import Log from '@libs/Log'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -26,11 +26,11 @@ export default function (): Promise { return resolve(); } - const onyxData: OnyxCollection = {}; + const onyxData: OnyxCollectionInputValue = {}; // Find all the transaction backups available Object.keys(transactions).forEach((transactionOnyxKey: string) => { - const transaction: Transaction | null = transactions[transactionOnyxKey]; + const transaction: OnyxEntry = transactions[transactionOnyxKey]; // Determine whether or not the transaction is a backup if (transactionOnyxKey.endsWith('-backup') && transaction) { diff --git a/src/libs/shouldIgnoreSelectionWhenUpdatedManually/index.android.tsx b/src/libs/shouldIgnoreSelectionWhenUpdatedManually/index.android.tsx new file mode 100644 index 000000000000..289c56ad69be --- /dev/null +++ b/src/libs/shouldIgnoreSelectionWhenUpdatedManually/index.android.tsx @@ -0,0 +1,5 @@ +import type ShouldIgnoreSelectionWhenUpdatedManually from './types'; + +const shouldIgnoreSelectionWhenUpdatedManually: ShouldIgnoreSelectionWhenUpdatedManually = true; + +export default shouldIgnoreSelectionWhenUpdatedManually; diff --git a/src/libs/shouldIgnoreSelectionWhenUpdatedManually/index.tsx b/src/libs/shouldIgnoreSelectionWhenUpdatedManually/index.tsx new file mode 100644 index 000000000000..744a94aa1f32 --- /dev/null +++ b/src/libs/shouldIgnoreSelectionWhenUpdatedManually/index.tsx @@ -0,0 +1,5 @@ +import type ShouldIgnoreSelectionWhenUpdatedManually from './types'; + +const shouldIgnoreSelectionWhenUpdatedManually: ShouldIgnoreSelectionWhenUpdatedManually = false; + +export default shouldIgnoreSelectionWhenUpdatedManually; diff --git a/src/libs/shouldIgnoreSelectionWhenUpdatedManually/types.ts b/src/libs/shouldIgnoreSelectionWhenUpdatedManually/types.ts new file mode 100644 index 000000000000..56394183ef7d --- /dev/null +++ b/src/libs/shouldIgnoreSelectionWhenUpdatedManually/types.ts @@ -0,0 +1,3 @@ +type ShouldIgnoreSelectionWhenUpdatedManually = boolean; + +export default ShouldIgnoreSelectionWhenUpdatedManually; diff --git a/src/libs/updateMultilineInputRange/index.ts b/src/libs/updateMultilineInputRange/index.ts index a0ad76096922..8bdccd83db90 100644 --- a/src/libs/updateMultilineInputRange/index.ts +++ b/src/libs/updateMultilineInputRange/index.ts @@ -15,9 +15,9 @@ const updateMultilineInputRange: UpdateMultilineInputRange = (input, shouldAutoF } if ('value' in input && input.value && input.setSelectionRange) { - const length = input.value.length; + const length = input.value.length as number; if (shouldAutoFocus) { - input.setSelectionRange(length, length); + (input as HTMLInputElement).setSelectionRange(length, length); } // eslint-disable-next-line no-param-reassign input.scrollTop = input.scrollHeight; diff --git a/src/pages/AddPersonalBankAccountPage.tsx b/src/pages/AddPersonalBankAccountPage.tsx index ac02cd26879b..e306fa0550ac 100644 --- a/src/pages/AddPersonalBankAccountPage.tsx +++ b/src/pages/AddPersonalBankAccountPage.tsx @@ -89,7 +89,9 @@ function AddPersonalBankAccountPage({personalBankAccount, plaidData}: AddPersona > Navigation.goBack()} receivedRedirectURI={getPlaidOAuthReceivedRedirectURI()} selectedPlaidAccountID={selectedPlaidAccountId} diff --git a/src/pages/ChatFinderPage/index.tsx b/src/pages/ChatFinderPage/index.tsx index 5cd26dafef44..0ff04deb09f0 100644 --- a/src/pages/ChatFinderPage/index.tsx +++ b/src/pages/ChatFinderPage/index.tsx @@ -193,6 +193,7 @@ function ChatFinderPage({betas, isSearchingForReports, navigation}: ChatFinderPa showLoadingPlaceholder={!areOptionsInitialized || !isScreenTransitionEnd} footerContent={!isDismissed && ChatFinderPageFooterInstance} isLoadingNewOptions={!!isSearchingForReports} + shouldDelayFocus={false} /> ); diff --git a/src/pages/EditReportFieldPage.tsx b/src/pages/EditReportFieldPage.tsx index 33502cc357b4..9afa0566af34 100644 --- a/src/pages/EditReportFieldPage.tsx +++ b/src/pages/EditReportFieldPage.tsx @@ -51,7 +51,7 @@ function EditReportFieldPage({route, policy, report}: EditReportFieldPageProps) const styles = useThemeStyles(); const fieldKey = ReportUtils.getReportFieldKey(route.params.fieldID); const reportField = report?.fieldList?.[fieldKey] ?? policy?.fieldList?.[fieldKey]; - const isDisabled = ReportUtils.isReportFieldDisabled(report, reportField ?? null, policy); + const isDisabled = ReportUtils.isReportFieldDisabled(report, reportField, policy); const [isDeleteModalVisible, setIsDeleteModalVisible] = useState(false); const {translate} = useLocalize(); diff --git a/src/pages/EnablePayments/AddBankAccount/AddBankAccount.tsx b/src/pages/EnablePayments/AddBankAccount/AddBankAccount.tsx index 79c91af178c3..a274990ea6a7 100644 --- a/src/pages/EnablePayments/AddBankAccount/AddBankAccount.tsx +++ b/src/pages/EnablePayments/AddBankAccount/AddBankAccount.tsx @@ -1,4 +1,4 @@ -import React, {useCallback, useEffect} from 'react'; +import React, {useCallback} from 'react'; import {View} from 'react-native'; import {withOnyx} from 'react-native-onyx'; import type {OnyxEntry} from 'react-native-onyx'; @@ -12,6 +12,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import Navigation from '@navigation/Navigation'; import * as BankAccounts from '@userActions/BankAccounts'; import * as PaymentMethods from '@userActions/PaymentMethods'; +import * as Wallet from '@userActions/Wallet'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; @@ -44,7 +45,6 @@ function AddBankAccount({personalBankAccount, plaidData, personalBankAccountDraf if (selectedPlaidBankAccount) { BankAccounts.addPersonalBankAccount(selectedPlaidBankAccount); - Navigation.navigate(ROUTES.SETTINGS_ENABLE_PAYMENTS); } }, [personalBankAccountDraft?.plaidAccountID, plaidData?.bankAccounts]); @@ -54,7 +54,6 @@ function AddBankAccount({personalBankAccount, plaidData, personalBankAccountDraf const exitFlow = (shouldContinue = false) => { const exitReportID = personalBankAccount?.exitReportID; - // TODO: https://github.com/Expensify/App/issues/36648 This should be updated to the correct route once the refactor is complete const onSuccessFallbackRoute = personalBankAccount?.onSuccessFallbackRoute ?? ''; if (exitReportID) { @@ -75,18 +74,19 @@ function AddBankAccount({personalBankAccount, plaidData, personalBankAccountDraf } if (screenIndex === 0) { BankAccounts.clearPersonalBankAccount(); + Wallet.updateCurrentStep(null); + Navigation.goBack(ROUTES.SETTINGS_WALLET); return; } prevScreen(); }; - useEffect(() => BankAccounts.clearPersonalBankAccount, []); - return ( {!!plaidDesktopMessage && ( - {translate(plaidDesktopMessage)} + {translate(plaidDesktopMessage)} )}