diff --git a/.eslintrc.js b/.eslintrc.js index ae3cedaec690..198620c70b0f 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -113,7 +113,6 @@ module.exports = { }, rules: { // TypeScript specific rules - '@typescript-eslint/no-unsafe-assignment': 'off', '@typescript-eslint/prefer-enum-initializers': 'error', '@typescript-eslint/no-var-requires': 'off', '@typescript-eslint/no-non-null-assertion': 'error', diff --git a/.github/actions/javascript/bumpVersion/bumpVersion.ts b/.github/actions/javascript/bumpVersion/bumpVersion.ts index ff43ab9ee5c5..92b81836ce13 100644 --- a/.github/actions/javascript/bumpVersion/bumpVersion.ts +++ b/.github/actions/javascript/bumpVersion/bumpVersion.ts @@ -49,7 +49,7 @@ if (!semanticVersionLevel || !versionUpdater.isValidSemverLevel(semanticVersionL console.log(`Invalid input for 'SEMVER_LEVEL': ${semanticVersionLevel}`, `Defaulting to: ${semanticVersionLevel}`); } -const {version: previousVersion}: PackageJson = JSON.parse(fs.readFileSync('./package.json').toString()); +const {version: previousVersion} = JSON.parse(fs.readFileSync('./package.json').toString()) as PackageJson; if (!previousVersion) { core.setFailed('Error: Could not read package.json'); } diff --git a/.github/actions/javascript/createOrUpdateStagingDeploy/createOrUpdateStagingDeploy.ts b/.github/actions/javascript/createOrUpdateStagingDeploy/createOrUpdateStagingDeploy.ts index aed8b9dcba0a..caff455e9fa5 100644 --- a/.github/actions/javascript/createOrUpdateStagingDeploy/createOrUpdateStagingDeploy.ts +++ b/.github/actions/javascript/createOrUpdateStagingDeploy/createOrUpdateStagingDeploy.ts @@ -8,13 +8,13 @@ import GitUtils from '@github/libs/GitUtils'; type IssuesCreateResponse = Awaited>['data']; -type PackageJSON = { +type PackageJson = { version: string; }; async function run(): Promise { // Note: require('package.json').version does not work because ncc will resolve that to a plain string at compile time - const packageJson: PackageJSON = JSON.parse(fs.readFileSync('package.json', 'utf8')); + const packageJson = JSON.parse(fs.readFileSync('package.json', 'utf8')) as PackageJson; const newVersionTag = packageJson.version; try { diff --git a/.github/actions/javascript/getGraphiteString/getGraphiteString.ts b/.github/actions/javascript/getGraphiteString/getGraphiteString.ts index 5231caa79ed5..93d5d8a9618b 100644 --- a/.github/actions/javascript/getGraphiteString/getGraphiteString.ts +++ b/.github/actions/javascript/getGraphiteString/getGraphiteString.ts @@ -33,7 +33,7 @@ const run = () => { } try { - const current: RegressionEntry = JSON.parse(entry); + const current = JSON.parse(entry) as RegressionEntry; // Extract timestamp, Graphite accepts timestamp in seconds if (current.metadata?.creationDate) { diff --git a/.github/actions/javascript/getPreviousVersion/getPreviousVersion.ts b/.github/actions/javascript/getPreviousVersion/getPreviousVersion.ts index a178d4073cbb..7799ffe7c9ec 100644 --- a/.github/actions/javascript/getPreviousVersion/getPreviousVersion.ts +++ b/.github/actions/javascript/getPreviousVersion/getPreviousVersion.ts @@ -11,7 +11,7 @@ function run() { core.setFailed(`'Error: Invalid input for 'SEMVER_LEVEL': ${semverLevel}`); } - const {version: currentVersion}: PackageJson = JSON.parse(readFileSync('./package.json', 'utf8')); + const {version: currentVersion} = JSON.parse(readFileSync('./package.json', 'utf8')) as PackageJson; if (!currentVersion) { core.setFailed('Error: Could not read package.json'); } diff --git a/.github/actions/javascript/validateReassureOutput/validateReassureOutput.ts b/.github/actions/javascript/validateReassureOutput/validateReassureOutput.ts index ad0f393a96a2..d843caf61518 100644 --- a/.github/actions/javascript/validateReassureOutput/validateReassureOutput.ts +++ b/.github/actions/javascript/validateReassureOutput/validateReassureOutput.ts @@ -3,7 +3,7 @@ import type {CompareResult, PerformanceEntry} from '@callstack/reassure-compare/ import fs from 'fs'; const run = (): boolean => { - const regressionOutput: CompareResult = JSON.parse(fs.readFileSync('.reassure/output.json', 'utf8')); + const regressionOutput = JSON.parse(fs.readFileSync('.reassure/output.json', 'utf8')) as CompareResult; const countDeviation = Number(core.getInput('COUNT_DEVIATION', {required: true})); const durationDeviation = Number(core.getInput('DURATION_DEVIATION_PERCENTAGE', {required: true})); diff --git a/__mocks__/@react-navigation/native/index.ts b/__mocks__/@react-navigation/native/index.ts index 0b7dda4621ad..5bcafdc1856c 100644 --- a/__mocks__/@react-navigation/native/index.ts +++ b/__mocks__/@react-navigation/native/index.ts @@ -1,9 +1,65 @@ -import {useIsFocused as realUseIsFocused, useTheme as realUseTheme} from '@react-navigation/native'; +/* eslint-disable import/prefer-default-export, import/no-import-module-exports */ +import type * as ReactNavigation from '@react-navigation/native'; +import createAddListenerMock from '../../../tests/utils/createAddListenerMock'; -// We only want these mocked for storybook, not jest -const useIsFocused: typeof realUseIsFocused = process.env.NODE_ENV === 'test' ? realUseIsFocused : () => true; +const isJestEnv = process.env.NODE_ENV === 'test'; -const useTheme = process.env.NODE_ENV === 'test' ? realUseTheme : () => ({}); +const realReactNavigation = isJestEnv ? jest.requireActual('@react-navigation/native') : (require('@react-navigation/native') as typeof ReactNavigation); + +const useIsFocused = isJestEnv ? realReactNavigation.useIsFocused : () => true; +const useTheme = isJestEnv ? realReactNavigation.useTheme : () => ({}); + +const {triggerTransitionEnd, addListener} = isJestEnv + ? createAddListenerMock() + : { + triggerTransitionEnd: () => {}, + addListener: () => {}, + }; + +const useNavigation = () => ({ + ...realReactNavigation.useNavigation, + navigate: jest.fn(), + getState: () => ({ + routes: [], + }), + addListener, +}); + +type NativeNavigationMock = typeof ReactNavigation & { + triggerTransitionEnd: () => void; +}; export * from '@react-navigation/core'; -export {useIsFocused, useTheme}; +const Link = realReactNavigation.Link; +const LinkingContext = realReactNavigation.LinkingContext; +const NavigationContainer = realReactNavigation.NavigationContainer; +const ServerContainer = realReactNavigation.ServerContainer; +const DarkTheme = realReactNavigation.DarkTheme; +const DefaultTheme = realReactNavigation.DefaultTheme; +const ThemeProvider = realReactNavigation.ThemeProvider; +const useLinkBuilder = realReactNavigation.useLinkBuilder; +const useLinkProps = realReactNavigation.useLinkProps; +const useLinkTo = realReactNavigation.useLinkTo; +const useScrollToTop = realReactNavigation.useScrollToTop; +export { + // Overriden modules + useIsFocused, + useTheme, + useNavigation, + triggerTransitionEnd, + + // Theme modules are left alone + Link, + LinkingContext, + NavigationContainer, + ServerContainer, + DarkTheme, + DefaultTheme, + ThemeProvider, + useLinkBuilder, + useLinkProps, + useLinkTo, + useScrollToTop, +}; + +export type {NativeNavigationMock}; diff --git a/__mocks__/@ua/react-native-airship.ts b/__mocks__/@ua/react-native-airship.ts index ae7661ab672f..14909b58b31c 100644 --- a/__mocks__/@ua/react-native-airship.ts +++ b/__mocks__/@ua/react-native-airship.ts @@ -15,31 +15,31 @@ const iOS: Partial = { }, }; -const pushIOS: AirshipPushIOS = jest.fn().mockImplementation(() => ({ +const pushIOS = jest.fn().mockImplementation(() => ({ setBadgeNumber: jest.fn(), setForegroundPresentationOptions: jest.fn(), setForegroundPresentationOptionsCallback: jest.fn(), -}))(); +}))() as AirshipPushIOS; -const pushAndroid: AirshipPushAndroid = jest.fn().mockImplementation(() => ({ +const pushAndroid = jest.fn().mockImplementation(() => ({ setForegroundDisplayPredicate: jest.fn(), -}))(); +}))() as AirshipPushAndroid; -const push: AirshipPush = jest.fn().mockImplementation(() => ({ +const push = jest.fn().mockImplementation(() => ({ iOS: pushIOS, android: pushAndroid, enableUserNotifications: () => Promise.resolve(false), clearNotifications: jest.fn(), getNotificationStatus: () => Promise.resolve({airshipOptIn: false, systemEnabled: false, airshipEnabled: false}), getActiveNotifications: () => Promise.resolve([]), -}))(); +}))() as AirshipPush; -const contact: AirshipContact = jest.fn().mockImplementation(() => ({ +const contact = jest.fn().mockImplementation(() => ({ identify: jest.fn(), getNamedUserId: () => Promise.resolve(undefined), reset: jest.fn(), module: jest.fn(), -}))(); +}))() as AirshipContact; const Airship: Partial = { addListener: jest.fn(), diff --git a/__mocks__/fs.ts b/__mocks__/fs.ts index cca0aa9520ec..3f8579557c82 100644 --- a/__mocks__/fs.ts +++ b/__mocks__/fs.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ const {fs} = require('memfs'); module.exports = fs; diff --git a/__mocks__/react-native.ts b/__mocks__/react-native.ts index 27b78b308446..3deeabf6df2a 100644 --- a/__mocks__/react-native.ts +++ b/__mocks__/react-native.ts @@ -41,7 +41,7 @@ jest.doMock('react-native', () => { }; }; - const reactNativeMock: ReactNativeMock = Object.setPrototypeOf( + const reactNativeMock = Object.setPrototypeOf( { NativeModules: { ...ReactNative.NativeModules, @@ -86,7 +86,7 @@ jest.doMock('react-native', () => { }, Dimensions: { ...ReactNative.Dimensions, - addEventListener: jest.fn(), + addEventListener: jest.fn(() => ({remove: jest.fn()})), get: () => dimensions, set: (newDimensions: Record) => { dimensions = newDimensions; @@ -98,11 +98,14 @@ jest.doMock('react-native', () => { // so it seems easier to just run the callback immediately in tests. InteractionManager: { ...ReactNative.InteractionManager, - runAfterInteractions: (callback: () => void) => callback(), + runAfterInteractions: (callback: () => void) => { + callback(); + return {cancel: () => {}}; + }, }, }, ReactNative, - ); + ) as ReactNativeMock; return reactNativeMock; }); diff --git a/android/app/build.gradle b/android/app/build.gradle index 5b21487d92cd..823974918b2a 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 1009000400 - versionName "9.0.4-0" + versionCode 1009000407 + versionName "9.0.4-7" // Supported language variants must be declared here to avoid from being removed during the compilation. // This also helps us to not include unnecessary language variants in the APK. resConfigs "en", "es" diff --git a/assets/images/computer.svg b/assets/images/computer.svg index 9c2628245eb1..be9eca391e0b 100644 --- a/assets/images/computer.svg +++ b/assets/images/computer.svg @@ -1,216 +1 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file + \ No newline at end of file diff --git a/assets/images/expensifyCard/cardIllustration.svg b/assets/images/expensifyCard/cardIllustration.svg new file mode 100644 index 000000000000..c81bb21568a7 --- /dev/null +++ b/assets/images/expensifyCard/cardIllustration.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/images/integrationicons/netsuite-icon-square.svg b/assets/images/integrationicons/netsuite-icon-square.svg index d4f19f4f44c0..1b4557c5a044 100644 --- a/assets/images/integrationicons/netsuite-icon-square.svg +++ b/assets/images/integrationicons/netsuite-icon-square.svg @@ -1,57 +1 @@ - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/assets/images/integrationicons/sage-intacct-icon-square.svg b/assets/images/integrationicons/sage-intacct-icon-square.svg index 33d86259a2d1..fe10342d711e 100644 --- a/assets/images/integrationicons/sage-intacct-icon-square.svg +++ b/assets/images/integrationicons/sage-intacct-icon-square.svg @@ -1,23 +1 @@ - - - - - - - - - - - \ No newline at end of file + \ No newline at end of file diff --git a/config/webpack/webpack.common.ts b/config/webpack/webpack.common.ts index bedd7e50ef94..33fd9131eca0 100644 --- a/config/webpack/webpack.common.ts +++ b/config/webpack/webpack.common.ts @@ -4,13 +4,13 @@ import dotenv from 'dotenv'; import fs from 'fs'; import HtmlWebpackPlugin from 'html-webpack-plugin'; import path from 'path'; -import type {Compiler, Configuration} from 'webpack'; +import type {Class} from 'type-fest'; +import type {Configuration, WebpackPluginInstance} 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; @@ -18,13 +18,10 @@ type Options = { include: string; }; -type PreloadWebpackPluginClass = { - new (options?: Options): PreloadWebpackPluginClass; - apply: (compiler: Compiler) => void; -}; +type PreloadWebpackPluginClass = Class; -// 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: PreloadWebpackPluginClass = require('@vue/preload-webpack-plugin'); +// require is necessary, importing anything from @vue/preload-webpack-plugin causes an error +const PreloadWebpackPlugin = require('@vue/preload-webpack-plugin') as PreloadWebpackPluginClass; const includeModules = [ 'react-native-animatable', diff --git a/desktop/main.ts b/desktop/main.ts index 6ab0bc6579d7..d8c46bbbc89b 100644 --- a/desktop/main.ts +++ b/desktop/main.ts @@ -141,7 +141,7 @@ const manuallyCheckForUpdates = (menuItem?: MenuItem, browserWindow?: BrowserWin autoUpdater .checkForUpdates() - .catch((error) => { + .catch((error: unknown) => { isSilentUpdating = false; return {error}; }) @@ -617,7 +617,7 @@ const mainWindow = (): Promise => { }); const downloadQueue = createDownloadQueue(); - ipcMain.on(ELECTRON_EVENTS.DOWNLOAD, (event, downloadData) => { + ipcMain.on(ELECTRON_EVENTS.DOWNLOAD, (event, downloadData: DownloadItem) => { const downloadItem: DownloadItem = { ...downloadData, win: browserWindow, diff --git a/docs/articles/expensify-classic/expensify-card/Auto-Reconciliation.md b/docs/articles/expensify-classic/expensify-card/Auto-Reconciliation.md deleted file mode 100644 index fb84e3484598..000000000000 --- a/docs/articles/expensify-classic/expensify-card/Auto-Reconciliation.md +++ /dev/null @@ -1,213 +0,0 @@ ---- -title: Expensify Card Auto-Reconciliation -description: Everything you need to know about Expensify Card Auto-Reconciliation ---- - - -# Overview -If your company uses the Expensify Visa® Commercial Card, and connects to a direct accounting integration, you can auto-reconcile card spending each month. - -The integrations that auto-reconciliation are available on are: - -- QuickBooks Online -- Xero -- NetSuite -- Sage Intacct - -# How-to Set Up Expensify Card Auto-Reconciliation - -## Auto-Reconciliation Prerequisites - -- Connection: -1. A Preferred Workspace is set. -2. A Reconciliation Account is set and matches the Expensify Card settlement account. -- Automation: -1. Auto-Sync is enabled on the Preferred Workspace above. -2. Scheduled Submit is enabled on the Preferred Workspace above. -- User: -1. A Domain Admin is set as the Preferred Workspace’s Preferred Exporter. - -To set up your auto-reconciliation account with the Expensify Card, follow these steps: -1. Navigate to your Settings. -2. Choose "Domains," then select your specific domain name. -3. Click on "Company Cards." -4. From the dropdown menu, pick the Expensify Card. -5. Head to the "Settings" tab. -6. Select the account in your accounting solution that you want to use for reconciliation. Make sure this account matches the settlement business bank account. - -![Company Card Settings section](https://help.expensify.com/assets/images/Auto-Reconciliaton_Image1.png){:width="100%"} - -That's it! You've successfully set up your auto-reconciliation account. - -## How does Auto-Reconciliation work -Once Auto-Reconciliation is enabled, there are a few things that happen. Let’s go over those! - -### Handling Purchases and Card Balance Payments -**What happens**: When an Expensify Card is used to make purchases, the amount spent is automatically deducted from your company’s 'Settlement Account' (your business checking account). This deduction happens on a daily or monthly basis, depending on your chosen settlement frequency. Don't worry; this settlement account is pre-defined when you apply for the Expensify Card, and you can't accidentally change it. -**Accounting treatment**: After your card balance is settled each day, we update your accounting system with a journal entry. This entry credits your bank account (referred to as the GL account) and debits the Expensify Card Clearing Account. To ensure accuracy, please make sure that the 'bank account' in your Expensify Card settings matches your real-life settlement account. You can easily verify this by navigating to **Settings > Account > Payments**, where you'll see 'Settlement Account' next to your business bank account. To keep track of settlement figures by date, use the Company Card Reconciliation Dashboard's Settlements tab: - -![Company Card Reconciliation Dashboard](https://help.expensify.com/assets/images/Auto-Reconciliation_Image2.png){:width="100%"} - -### Submitting, Approving, and Exporting Expenses -**What happens**: Users submit their expenses on a report, which might occur after some time has passed since the initial purchase. Once the report is approved, it's then exported to your accounting software. -**Accounting treatment**: When the report is exported, we create a journal entry in your accounting system. This entry credits the Clearing Account and debits the Liability Account for the purchase amount. The Liability Account functions as a bank account in your ledger, specifically for Expensify Card expenses. - -# Deep Dive -## QuickBooks Online - -### Initial Setup -1. Start by accessing your group workspace linked to QuickBooks Online. On the Export tab, make sure that the user chosen as the Preferred Exporter holds the role of a Workspace Admin and has an email address associated with your Expensify Cards' domain. For instance, if your domain is company.com, the Preferred Exporter's email should be email@company.com. -2. Head over to the Advanced tab and ensure that Auto-Sync is enabled. -3. Now, navigate to **Settings > Domains > *Domain Name* > Company Cards > Settings**. Use the dropdown menu next to "Preferred Workspace" to select the group workspace connected to QuickBooks Online and with Scheduled Submit enabled. -4. In the dropdown menu next to "Expensify Card reconciliation account," choose your existing QuickBooks Online bank account for reconciliation. This should be the same account you use for Expensify Card settlements. -5. In the dropdown menu next to "Expensify Card settlement account," select your business bank account used for settlements (found in Expensify under **Settings > Account > Payments**). - -### How This Works -1. On the day of your first card settlement, we'll create the Expensify Card Liability account in your QuickBooks Online general ledger. If you've opted for Daily Settlement, we'll also create an Expensify Clearing Account. -2. During your QuickBooks Online auto-sync on that same day, if there are unsettled transactions, we'll generate a journal entry totaling all posted transactions since the last settlement. This entry will credit the selected bank account and debit the new Expensify Clearing Account (for Daily Settlement) or the Expensify Liability Account (for Monthly Settlement). -3. Once the transactions are posted and the expense report is approved in Expensify, the report will be exported to QuickBooks Online with each line as individual card expenses. For Daily Settlement, an additional journal entry will credit the Expensify Clearing Account and debit the Expensify Card Liability Account. For Monthly Settlement, the journal entry will credit the Liability account directly and debit the appropriate expense categories. - -### Example -- We have card transactions for the day totaling $100, so we create the following journal entry upon sync: -![QBO Journal Entry](https://help.expensify.com/assets/images/Auto-Reconciliation QBO 1.png){:width="100%"} -- The current balance of the Expensify Clearing Account is now $100: -![QBO Clearing Account](https://help.expensify.com/assets/images/Auto-reconciliation QBO 2.png){:width="100%"} -- After transactions are posted in Expensify and the report is approved and exported, a second journal entry is generated: -![QBO Second Journal Entry](https://help.expensify.com/assets/images/Auto-reconciliation QBO 3.png){:width="100%"} -- We reconcile the matching amounts automatically, clearing the balance of the Expensify Clearing Account: -![QBO Clearing Account 2](https://help.expensify.com/assets/images/Auto-reconciliation QBO 4.png){:width="100%"} -- Now, you'll have a debit on your credit card account (increasing the total spent) and a credit on the bank account (reducing the available amount). The Clearing Account balance is $0. -- Each expense will also create a credit card expense, similar to how we do it today, exported upon final approval. This action debits the expense account (category) and includes any other line item data. -- This process occurs daily during the QuickBooks Online Auto-Sync to ensure your card remains reconciled. - -**Note:** If Auto-Reconciliation is disabled for your company's Expensify Cards, a Domain Admin can set an export account for individual cards via **Settings > Domains > *Domain Name* > Company Cards > Edit Exports**. The Expensify Card transactions will always export as Credit Card charges in your accounting software, even if the non-reimbursable setting is configured differently, such as a Vendor Bill. - -## Xero - -### Initial Setup -1. Begin by accessing your group workspace linked to Xero. On the Export tab, ensure that the Preferred Exporter is a Workspace Admin with an email address from your Expensify Cards domain (e.g. company.com). -2. Head to the Advanced tab and confirm that Auto-Sync is enabled. -3. Now, go to **Settings > Domains > *Domain Name* > Company Cards > Settings**. From the dropdown menu next to "Preferred Workspace," select the group workspace connected to Xero with Scheduled Submit enabled. -4. In the dropdown menu for "Expensify Card settlement account," pick your settlement business bank account (found in Expensify under **Settings > Account > Payments**). -5. In the dropdown menu for "Expensify Card reconciliation account," select the corresponding GL account from Xero for your settlement business bank account from step 4. - -### How This Works -1. During the first overnight Auto Sync after enabling Continuous Reconciliation, Expensify will create a Liability Account (Bank Account) on your Xero Dashboard. If you've opted for Daily Settlement, an additional Clearing Account will be created in your General Ledger. Two Contacts —Expensify and Expensify Card— will also be generated: -![Xero Contacts](https://help.expensify.com/assets/images/Auto-reconciliation Xero 1.png){:width="100%"} -2. The bank account for Expensify Card transactions is tied to the Liability Account Expensify created. Note that this doesn't apply to other cards or non-reimbursable expenses, which follow your workspace settings. - -### Daily Settlement Reconciliation -- If you've selected Daily Settlement, Expensify uses entries in the Clearing Account to reconcile the daily settlement. This is because Expensify bills on posted transactions, which you can review via **Settings > Domains > *Domain Name* > Company Cards > Reconciliation > Settlements**. -- At the end of each day (or month on your settlement date), the settlement charge posts to your Business Bank Account. Expensify assigns the Clearing Account (or Liability Account for monthly settlement) as a Category to the transaction, posting it in your GL. The charge is successfully reconciled. - -### Bank Transaction Reconciliation -- Expensify will pay off the Liability Account with the Clearing Account balance and reconcile bank transaction entries to the Liability Account with your Expense Accounts. -- When transactions are approved and exported from Expensify, bank transactions (Receive Money) are added to the Liability Account, and coded to the Clearing Account. Simultaneously, Spend Money transactions are created and coded to the Category field. If you see many Credit Card Misc. entries, add commonly used merchants as Contacts in Xero to export with the original merchant name. -- The Clearing Account balance is reduced, paying off the entries to the Liability Account created in Step 1. Each payment to and from the Liability Account should have a corresponding bank transaction referencing an expense account. Liability Account Receive Money payments appear with "EXPCARD-APPROVAL" and the corresponding Report ID from Expensify. -- You can run a Bank Reconciliation Summary displaying entries in the Liability Account referencing individual payments, as well as entries that reduce the Clearing Account balance to unapproved expenses. -- **Important**: To bring your Liability Account balance to 0, enable marking transactions as reconciled in Xero. When a Spend Money bank transaction in the Liability Account has a matching Receive Transaction, you can mark both as Reconciled using the provided hyperlink. - -**Note**: If Auto-Reconciliation is disabled for your company's Expensify Cards, a Domain Admin can set an export account for individual cards via **Settings > Domains > *Domain Name* > Company Cards > Edit Exports**. The Expensify Card transactions will always export as a Credit Card charge in your accounting software, regardless of the non-reimbursable setting in their accounting configuration. - -## NetSuite - -### Initial Setup -1. Start by accessing your group workspace connected to NetSuite and click on "Configure" under **Connections > NetSuite**. -2. On the Export tab, ensure that the Preferred Exporter is a Workspace Admin with an email address from your Expensify Cards' domain. For example, if your domain is company.com, the Preferred Exporter's email should be email@company.com. -3. Head over to the Advanced tab and make sure Auto-Sync is enabled. -4. Now, go to **Settings > Domains > *Domain Name* > Company Cards > Settings**. From the dropdown menu next to "Preferred Workspace," select the group workspace connected to NetSuite with Scheduled Submit enabled. -5. In the dropdown menu next to "Expensify Card reconciliation account," choose your existing NetSuite bank account used for reconciliation. This account must match the one set in Step 3. -6. In the dropdown menu next to "Expensify Card settlement account," select your daily settlement business bank account (found in Expensify under **Settings > Account > Payments**). - -### How This Works with Daily Settlement -1. After setting up the card and running the first auto-sync, we'll create the Expensify Card Liability account and the Expensify Clearing Account within your NetSuite subsidiary general ledger. -2. During the same sync, if there are newly posted transactions, we'll create a journal entry totaling all posted transactions for the day. This entry will credit the selected bank account and debit the new Expensify Clearing account. -3. Once transactions are approved in Expensify, the report will be exported to NetSuite, with each line recorded as individual credit card expenses. Additionally, another journal entry will be generated, crediting the Expensify Clearing Account and debiting the Expensify Card Liability account. - -### How This Works with Monthly Settlement -1. After the first monthly settlement, during Auto-Sync, Expensify creates a Liability Account in NetSuite (without a clearing account). -2. Each time the monthly settlement occurs, Expensify calculates the total purchase amount since the last settlement and creates a Journal Entry that credits the settlement bank account (GL Account) and debits the Expensify Liability Account in NetSuite. -3. As expenses are approved and exported to NetSuite, Expensify credits the Liability Account and debits the correct expense categories. - -**Note**: By default, the Journal Entries created by Expensify are set to the approval level "Approved for posting," so they will automatically credit and debit the appropriate accounts. If you have "Require approval on Journal Entries" enabled in your accounting preferences in NetSuite (**Setup > Accounting > Accounting Preferences**), this will override that default. Additionally, if you have set up Custom Workflows (**Customization > Workflow**), these can also override the default. In these cases, the Journal Entries created by Expensify will post as "Pending approval." You will need to approve these Journal Entries manually to complete the reconciliation process. - -### Example -- Let's say you have card transactions totaling $100 for the day. -- We create a journal entry: -![NetSuite Journal Entry](https://help.expensify.com/assets/images/Auto-reconciliation NS 1.png){:width="100%"} -- After transactions are posted in Expensify, we create the second Journal Entry(ies): -![NetSuite Second Journal Entry](https://help.expensify.com/assets/images/Auto-reconciliation NS 2.png){:width="100%"} -- We then reconcile the matching amounts automatically, clearing the balance of the Expensify Clearing Account. -- Now, you'll have a debit on your Credit Card account (increasing the total spent) and a credit on the bank account (reducing the amount available). The clearing account has a $0 balance. -- Each expense will also create a Journal Entry, just as we do today, exported upon final approval. This entry will debit the expense account (category) and contain any other line item data. -- This process happens daily during the NetSuite Auto-Sync to keep your card reconciled. - -**Note**: Currently, only Journal Entry export is supported for auto-reconciliation. You can set other export options for all other non-reimbursable spend in the **Configure > Export** tab. Be on the lookout for Expense Report export in the future! - -If Auto-Reconciliation is disabled for your company's Expensify Cards, a Domain Admin can set an export account for individual Expensify Cards via **Settings > Domains > Company Cards > Edit Exports**. The Expensify Card transactions will always export as a Credit Card charge in your accounting software, regardless of the non-reimbursable setting in their accounting configuration. - -## Sage Intacct - -### Initial Setup -1. Start by accessing your group workspace connected to Sage Intacct and click on "Configure" under **Connections > Sage Intacct**. -2. On the Export tab, ensure that you've selected a specific entity. To enable Expensify to create the liability account, syncing at the entity level is crucial, especially for multi-entity environments. -3. Still on the Export tab, confirm that the user chosen as the Preferred Exporter is a Workspace Admin, and their email address belongs to the domain used for Expensify Cards. For instance, if your domain is company.com, the Preferred Exporter's email should be email@company.com. -4. Head over to the Advanced tab and make sure Auto-Sync is enabled. -5. Now, go to **Settings > Domains > *Domain Name* > Company Cards > Settings**. From the dropdown menu next to "Preferred Workspace," select the group workspace connected to Sage Intacct with Scheduled Submit enabled. -6. In the dropdown menu next to "Expensify Card reconciliation account" pick your existing Sage Intacct bank account used for daily settlement. This account must match the one set in the next step. -7. In the dropdown menu next to "Expensify Card settlement account" select your daily settlement business bank account (found in Expensify under **Settings > Account > Payments**). -8. Use the dropdown menus to select your cash-only and accrual-only journals. If your organization operates on a cash-only or accrual-only basis, choose "No Selection" for the journals as needed. If your organization uses both cash and accrual methods, please select both a cash-only and an accrual-only journal. Don't forget to save your settings! - -### How This Works with Daily Settlement -1. After setting up the card and running the first auto-sync, we'll create the Expensify Card Expensify Clearing Account within your Sage Intacct general ledger. Once the first card transaction is exported, we'll create a Liability Account. -2. In the same sync, if there are newly posted transactions from your Expensify Cards, we'll then create a journal entry totaling all posted transactions for the day. This entry will credit the business bank account (set in Step 4 above) and debit the new Expensify Clearing account. -3. Once Expensify Card transactions are approved in Expensify, the report will be exported to Sage Intacct, with each line recorded as individual credit card expenses. Additionally, another journal entry will be generated, crediting the Expensify Clearing Account and debiting the Expensify Card Liability Account. - -### How This Works with Monthly Settlement -1. After the initial export of a card transaction, Expensify establishes a Liability Account in Intacct (without a clearing account). -2. Each time a monthly settlement occurs, Expensify calculates the total purchase amount since the last settlement and creates a Journal Entry. This entry credits the settlement bank account (GL Account) and debits the Expensify Liability Account in Intacct. -3. As expenses are approved and exported to Intacct, Expensify credits the Liability Account and debits the appropriate expense categories. - -{% include faq-begin.md %} - -## What are the timeframes for auto-reconciliation in Expensify? -We offer either daily or monthly auto-reconciliation: -- Daily Settlement: each day, as purchases are made on your Expensify Cards, the posted balance is withdrawn from your Expensify Card Settlement Account (your business bank account). -- Monthly Settlement: each month, on the day of the month that you enabled Expensify Cards (or switched from Daily to Monthly Settlement), the posted balance of all purchases since the last settlement payment is withdrawn from your Expensify Card Settlement Account (your business bank account). - -## Why is my Expensify Card auto-reconciliation not working with Xero? -When initially creating the Liability and Bank accounts to complete the auto-reconciliation process, we rely on the system to match and recognize those accounts created. You can't make any changes or we will not “find” those accounts. - -If you have changed the accounts. It's an easy fix, just rename them! -- Internal Account Code: must be **ExpCardLbl** -- Account Type: must be **Bank** - -## My accounting integration is not syncing. How will this affect the Expensify Card auto-reconciliation? -When you receive a message that your accounting solution’s connection failed to sync, you will also receive an email or error message with the steps to correct the sync issue. If you do not, please contact Support for help. When your accounting solution’s sync reconnects and is successful, your auto-reconciliation will resume. - -If your company doesn't have auto-reconciliation enabled for its Expensify Cards, you can still set up individual export accounts. Here's how: - -1. Make sure you have Domain Admin privileges. -2. Navigate to **Settings > Domains** -3. Select 'Company Cards' -4. Find the Expensify Card you want to configure and choose 'Edit Exports.' -5. Pick the export account where you want the Expensify Card transactions to be recorded. -6. Please note that these transactions will always be exported as Credit Card charges in your accounting software. This remains the case even if you've configured non-reimbursable settings as something else, such as a Vendor Bill. - -These simple steps will ensure your Expensify Card transactions are correctly exported to the designated account in your accounting software. - -## Why does my Expensify Card Liability Account have a balance? -If you’re using the Expensify Card with auto-reconciliation, your Expensify Card Liability Account balance should always be $0 in your accounting system. - -If you see that your Expensify Card Liability Account balance isn’t $0, then you’ll need to take action to return that balance to $0. - -If you were using Expensify Cards before auto-reconciliation was enabled for your accounting system, then any expenses that occurred prior will not be cleared from the Liability Account. -You will need to prepare a manual journal entry for the approved amount to bring the balance to $0. - -To address this, please follow these steps: -1. Identify the earliest date of a transaction entry in the Liability Account that doesn't have a corresponding entry. Remember that each expense will typically have both a positive and a negative entry in the Liability Account, balancing out to $0. -2. Go to the General Ledger (GL) account where your daily Expensify Card settlement withdrawals are recorded, and locate entries for the dates identified in Step 1. -3. Adjust each settlement entry so that it now posts to the Clearing Account. -4. Create a Journal Entry or Receive Money Transaction to clear the balance in the Liability Account using the funds currently held in the Clearing Account, which was set up in Step 2. - -{% include faq-end.md %} diff --git a/docs/articles/expensify-classic/expensify-card/Expensify-Card-Reconciliation.md b/docs/articles/expensify-classic/expensify-card/Expensify-Card-Reconciliation.md new file mode 100644 index 000000000000..81eae56fa774 --- /dev/null +++ b/docs/articles/expensify-classic/expensify-card/Expensify-Card-Reconciliation.md @@ -0,0 +1,108 @@ +--- +title: Expensify Card reconciliation +description: Reconcile expenses from Expensify Cards +--- + +
+ +To handle unapproved Expensify Card expenses that are left after you close your books for the month, you can set up auto-reconciliation with an accounting integration, or you can manually reconcile the expenses. + +# Set up automatic reconciliation + +Auto-reconciliation automatically deducts Expensify Card purchases from your company’s settlement account on a daily or monthly basis. + +{% include info.html %} +You must link a business bank account as your settlement account before you can complete this process. +{% include end-info.html %} + +1. Hover over Settings, then click **Domains**. +2. Click the desired domain name. +3. On the Company Cards tab, click the dropdown under the Imported Cards section to select the desired Expensify Card. +4. To the right of the dropdown, click the **Settings** tab. +5. Click the Expensify Card settlement account dropdown and select your settlement business bank account. + - To verify which account is your settlement account: Hover over Settings, then click **Account**. Click the **Payments** tab on the left and verify the bank account listed as the Settlement Account. If these accounts do not match, repeat the steps above to select the correct bank account. +6. Click **Save**. + +If your workspace is connected to a QuickBooks Online, Xero, NetSuite, or Sage Intacct integration, complete the following additional steps. + +1. Click the Expensify Card Reconciliation Account dropdown and select the GL account from your integration for your Settlement Account. Then click **Save**. +2. (Optional) If using the Sage Intacct integration, select your cash-only and accrual-only journals. If your organization operates on a cash-only or accrual-only basis, choose **No Selection** for the journals that do not apply. +3. Click the **Advanced** tab and ensure Auto-Sync is enabled. Then click **Save** +4. Hover over **Settings**, then click **Workspaces**. +5. Open the workspace linked to the integration. +6. Click the **Connections** tab. +7. Next to the desired integration, click **Configure**. +8. Under the Export tab, ensure that the Preferred Exporter is also a Workspace Admin and has an email address associated with your Expensify Cards' domain. For example, if your domain is company.com, the Preferred Exporter's email should be name@company.com. + +# Manually reconcile expenses + +To manually reconcile Expensify Card expenses, + +1. Hover over Settings, then click **Domains**. +2. Click the desired domain name. +3. On the Company Cards tab, click the dropdown under the Imported Cards section to select the desired Expensify Card. +4. To the right of the dropdown, click the **Reconciliation** tab. +5. For the Reconcile toggle, ensure Expenses is selected. +6. Select the start and end dates, then click **Run**. +7. Use the Imported, Approved, and Unapproved totals to manually reconcile your clearing account in your accounting system. + - The Unapproved total should match the final clearing account balance. Depending on your accounting policies, you can use this balance to book an accrual entry by debiting the appropriate expense and crediting the offsetting clearing account in your accounting system. + +## Troubleshooting + +Use the steps below to do additional research if: +- The amounts vary to a degree that needs further investigation. +- The Reconciliation tab was not run when the accounts payable (AP) was closed. +- Multiple subsidiaries within the accounting system closed on different dates. +- There are foreign currency implications in the accounting system. + +To do a more in-depth reconciliation, + +1. In your accounting system, lock your AP. + +{% include info.html %} +It’s best to do this step at the beginning or end of the day. Otherwise, expenses with the same export date may be posted in different accounting periods. +{% include end-info.html %} + +2. In Expensify, click the **Reports** tab. +3. Set the From date filter to the first day of the month or the date of the first applicable Expensify Card expense, and set the To date filter to today’s date. +4. Set the other filters to show **All**. +5. Select all of the expense reports by clicking the checkbox to the top left of the list. If you have more than 50 expense reports, click **Select All**. +6. In the top right corner of the page, click **Export To** and select **All Data - Expense Level Export**. This will generate and send a CSV report to your email. +7. Click the link from the email to automatically download a copy of the report to your computer. +8. Open the report and apply the following filters (or create a pivot with these filters) depending on whether you want to view the daily or monthly settlements: + - Daily settlements: + - Date = the month you are reconciling + - Bank = Expensify Card + - Posted Date = the month you are reconciling + - [Accounting system] Export Non Reimb = blank/after your AP lock date + - Monthly settlements: + - Date = the month you are reconciling + - Bank = Expensify Card + - Posted Date = The first date after your last settlement until the end of the month + - [Accounting system] Export Non Reimb = the current month and new month until your AP lock date + - To determine your total Expensify Card liability at the end of the month, set this filter to blank/after your AP lock date. + +This filtered list should now only include Expensify Card expenses that have a settlement/card payment entry in your accounting system but don’t have a corresponding expense entry (because they have not yet been approved in Expensify). The sum is shown at the bottom of the sheet. + +The sum of the expenses should equal the balance in your Expensify Clearing or Liability Account in your accounting system. + +# Tips + +- Enable [Scheduled Submit](https://help.expensify.com/articles/expensify-classic/workspaces/reports/Scheduled-Submit) to ensure that expenses are submitted regularly and on time. +- Expenses that remain unapproved for several months can complicate the reconciliation process. If you're an admin in Expensify, you can communicate with all employees who have an active Expensify account by going to [new.expensify.com](http://new.expensify.com) and using the #announce room to send a message. This way, you can remind employees to ensure their expenses are submitted and approved before the end of each month. +- Keep in mind that although Expensify Card settlements/card payments will post to your general ledger on the date it is recorded in Expensify, the payment may not be withdrawn from your bank account until the following business day. +- Based on your internal policies, you may want to accrue for the Expensify Cards. + +{% include faq-begin.md %} + +**Why is the amount in my Expensify report so different from the amount in my accounting system?** + +If the Expensify report shows an amount that is significantly different to your accounting system, there are a few ways to identify the issues: +- Double check that the expenses posted to the GL are within the correct month. Filter out these expenses to see if they now match those in the CSV report. +- Use the process outlined above to export a report of all the transactions from your Clearing (for Daily Settlement) or Liability (for monthly settlement) account, then create a pivot table to group the transactions into expenses and settlements. + - Run the settlements report in the “settlements” view of the Reconciliation Dashboard to confirm that the numbers match. + - Compare “Approved” activity to your posted activity within your accounting system to confirm the numbers match. + +{% include faq-end.md %} + +
diff --git a/docs/assets/images/ExpensifyHelp-Invoice-1.png b/docs/assets/images/ExpensifyHelp-Invoice-1.png index e4a042afef82..a6dda9fdca92 100644 Binary files a/docs/assets/images/ExpensifyHelp-Invoice-1.png and b/docs/assets/images/ExpensifyHelp-Invoice-1.png differ diff --git a/docs/assets/images/ExpensifyHelp-QBO-1.png b/docs/assets/images/ExpensifyHelp-QBO-1.png index 2aa80e954f1b..e20a5e4222d0 100644 Binary files a/docs/assets/images/ExpensifyHelp-QBO-1.png and b/docs/assets/images/ExpensifyHelp-QBO-1.png differ diff --git a/docs/assets/images/ExpensifyHelp-QBO-2.png b/docs/assets/images/ExpensifyHelp-QBO-2.png index 23419b86b6aa..66b71b8d8ec8 100644 Binary files a/docs/assets/images/ExpensifyHelp-QBO-2.png and b/docs/assets/images/ExpensifyHelp-QBO-2.png differ diff --git a/docs/assets/images/ExpensifyHelp-QBO-3.png b/docs/assets/images/ExpensifyHelp-QBO-3.png index c612cb760d58..f96550868bbd 100644 Binary files a/docs/assets/images/ExpensifyHelp-QBO-3.png and b/docs/assets/images/ExpensifyHelp-QBO-3.png differ diff --git a/docs/assets/images/ExpensifyHelp-QBO-4.png b/docs/assets/images/ExpensifyHelp-QBO-4.png index 7fbc99503f2e..c7b85a93b04b 100644 Binary files a/docs/assets/images/ExpensifyHelp-QBO-4.png and b/docs/assets/images/ExpensifyHelp-QBO-4.png differ diff --git a/docs/assets/images/ExpensifyHelp-QBO-5.png b/docs/assets/images/ExpensifyHelp-QBO-5.png index 600a5903c05f..99b83b8be2d1 100644 Binary files a/docs/assets/images/ExpensifyHelp-QBO-5.png and b/docs/assets/images/ExpensifyHelp-QBO-5.png differ diff --git a/docs/redirects.csv b/docs/redirects.csv index f2d9a797415b..67ca238c1aed 100644 --- a/docs/redirects.csv +++ b/docs/redirects.csv @@ -203,6 +203,7 @@ https://help.expensify.com/articles/new-expensify/chat/Expensify-Chat-For-Admins https://help.expensify.com/articles/new-expensify/bank-accounts-and-payments/Connect-a-Bank-Account.html,https://help.expensify.com/articles/new-expensify/expenses/Connect-a-Business-Bank-Account https://help.expensify.com/articles/expensify-classic/travel/Coming-Soon,https://help.expensify.com/expensify-classic/hubs/travel/ https://help.expensify.com/articles/new-expensify/expenses/Manually-submit-reports-for-approval,https://help.expensify.com/new-expensify/hubs/expenses/ +https://help.expensify.com/articles/expensify-classic/expensify-card/Auto-Reconciliation,https://help.expensify.com/articles/expensify-classic/expensify-card/Expensify-Card-Reconciliation.md https://help.expensify.com/articles/new-expensify/expenses/Approve-and-pay-expenses,https://help.expensify.com/articles/new-expensify/expenses-&-payments/Approve-and-pay-expenses https://help.expensify.com/articles/new-expensify/expenses/Connect-a-Business-Bank-Account,https://help.expensify.com/articles/new-expensify/expenses-&-payments/Connect-a-Business-Bank-Account https://help.expensify.com/articles/new-expensify/expenses/Create-an-expense,https://help.expensify.com/articles/new-expensify/expenses-&-payments/Create-an-expense diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index c1aae6e1265d..a6b9d8632061 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -40,7 +40,7 @@ CFBundleVersion - 9.0.4.0 + 9.0.4.7 FullStory OrgId diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index 579c99455525..ce34b27d72e3 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -19,6 +19,6 @@ CFBundleSignature ???? CFBundleVersion - 9.0.4.0 + 9.0.4.7 diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index 7981169f076b..9b89c5e2790f 100644 --- a/ios/NotificationServiceExtension/Info.plist +++ b/ios/NotificationServiceExtension/Info.plist @@ -13,7 +13,7 @@ CFBundleShortVersionString 9.0.4 CFBundleVersion - 9.0.4.0 + 9.0.4.7 NSExtension NSExtensionPointIdentifier diff --git a/jest/setup.ts b/jest/setup.ts index f11a8a4ed631..c1a737c5def8 100644 --- a/jest/setup.ts +++ b/jest/setup.ts @@ -1,6 +1,8 @@ import '@shopify/flash-list/jestSetup'; import 'react-native-gesture-handler/jestSetup'; +import type * as RNKeyboardController from 'react-native-keyboard-controller'; import mockStorage from 'react-native-onyx/dist/storage/__mocks__'; +import type Animated from 'react-native-reanimated'; import 'setimmediate'; import mockFSLibrary from './setupMockFullstoryLib'; import setupMockImages from './setupMockImages'; @@ -20,6 +22,16 @@ jest.mock('react-native-onyx/dist/storage', () => mockStorage); // Mock NativeEventEmitter as it is needed to provide mocks of libraries which include it jest.mock('react-native/Libraries/EventEmitter/NativeEventEmitter'); +// Needed for: https://stackoverflow.com/questions/76903168/mocking-libraries-in-jest +jest.mock('react-native/Libraries/LogBox/LogBox', () => ({ + // eslint-disable-next-line @typescript-eslint/naming-convention + __esModule: true, + default: { + ignoreLogs: jest.fn(), + ignoreAllLogs: jest.fn(), + }, +})); + // 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: string[]) => { if (params[0].startsWith('Timing:')) { @@ -54,5 +66,10 @@ jest.mock('react-native-share', () => ({ default: jest.fn(), })); -// eslint-disable-next-line @typescript-eslint/no-unsafe-return -jest.mock('react-native-keyboard-controller', () => require('react-native-keyboard-controller/jest')); +jest.mock('react-native-reanimated', () => ({ + ...jest.requireActual('react-native-reanimated/mock'), + createAnimatedPropAdapter: jest.fn, + useReducedMotion: jest.fn, +})); + +jest.mock('react-native-keyboard-controller', () => require('react-native-keyboard-controller/jest')); diff --git a/jest/setupMockFullstoryLib.ts b/jest/setupMockFullstoryLib.ts index 9edfccab9441..eae3ea1f51bd 100644 --- a/jest/setupMockFullstoryLib.ts +++ b/jest/setupMockFullstoryLib.ts @@ -15,7 +15,7 @@ export default function mockFSLibrary() { return { FSPage(): FSPageInterface { return { - start: jest.fn(), + start: jest.fn(() => {}), }; }, default: Fullstory, diff --git a/package-lock.json b/package-lock.json index c92ca2ec3813..abc223a50b38 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "9.0.4-0", + "version": "9.0.4-7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "9.0.4-0", + "version": "9.0.4-7", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -102,7 +102,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.54", + "react-native-onyx": "2.0.55", "react-native-pager-view": "6.2.3", "react-native-pdf": "6.7.3", "react-native-performance": "^5.1.0", @@ -248,7 +248,7 @@ "ts-jest": "^29.1.2", "ts-node": "^10.9.2", "tsconfig-paths": "^4.2.0", - "type-fest": "^4.10.2", + "type-fest": "4.20.0", "typescript": "^5.4.5", "wait-port": "^0.2.9", "webpack": "^5.76.0", @@ -37277,9 +37277,9 @@ } }, "node_modules/react-native-onyx": { - "version": "2.0.54", - "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-2.0.54.tgz", - "integrity": "sha512-cANbs0KuiwHAIUC0HY7DGNXbFMHH4ZWbTci+qhHhuNNf4aNIP0/ncJ4W8a3VCgFVtfobIFAX5ouT40dEcgBOIQ==", + "version": "2.0.55", + "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-2.0.55.tgz", + "integrity": "sha512-W0+hFY98uC3uije2JBFS1ON19iAe8u6Ls50T2Qrx9NMtzUFqEchMuR75L4F/kMvi/uwtQII+Cl02Pd52h/tdPg==", "dependencies": { "ascii-table": "0.0.9", "fast-equals": "^4.0.3", @@ -41840,9 +41840,10 @@ } }, "node_modules/type-fest": { - "version": "4.10.3", + "version": "4.20.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.20.0.tgz", + "integrity": "sha512-MBh+PHUHHisjXf4tlx0CFWoMdjx8zCMLJHOjnV1prABYZFHqtFOyauCIK2/7w4oIfwkF8iNhLtnJEfVY2vn3iw==", "dev": true, - "license": "(MIT OR CC0-1.0)", "engines": { "node": ">=16" }, diff --git a/package.json b/package.json index e4bd1d99db16..bc7306ee3782 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "9.0.4-0", + "version": "9.0.4-7", "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.", @@ -155,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.54", + "react-native-onyx": "2.0.55", "react-native-pager-view": "6.2.3", "react-native-pdf": "6.7.3", "react-native-performance": "^5.1.0", @@ -301,7 +301,7 @@ "ts-jest": "^29.1.2", "ts-node": "^10.9.2", "tsconfig-paths": "^4.2.0", - "type-fest": "^4.10.2", + "type-fest": "4.20.0", "typescript": "^5.4.5", "wait-port": "^0.2.9", "webpack": "^5.76.0", diff --git a/patches/@react-navigation+core+6.4.11+001+fix-react-strictmode.patch b/patches/@react-navigation+core+6.4.11+001+fix-react-strictmode.patch index 8941bb380a79..f68cd6fe9ca4 100644 --- a/patches/@react-navigation+core+6.4.11+001+fix-react-strictmode.patch +++ b/patches/@react-navigation+core+6.4.11+001+fix-react-strictmode.patch @@ -42,3 +42,48 @@ index 051520b..6fb49e0 100644 }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); +diff --git a/node_modules/@react-navigation/core/src/useNavigationBuilder.tsx b/node_modules/@react-navigation/core/src/useNavigationBuilder.tsx +index b1971ba..7d550e0 100644 +--- a/node_modules/@react-navigation/core/src/useNavigationBuilder.tsx ++++ b/node_modules/@react-navigation/core/src/useNavigationBuilder.tsx +@@ -362,11 +362,6 @@ export default function useNavigationBuilder< + + const stateCleanedUp = React.useRef(false); + +- const cleanUpState = React.useCallback(() => { +- setCurrentState(undefined); +- stateCleanedUp.current = true; +- }, [setCurrentState]); +- + const setState = React.useCallback( + (state: NavigationState | PartialState | undefined) => { + if (stateCleanedUp.current) { +@@ -540,6 +535,9 @@ export default function useNavigationBuilder< + state = nextState; + + React.useEffect(() => { ++ // In strict mode, React will double-invoke effects. ++ // So we need to reset the flag if component was not unmounted ++ stateCleanedUp.current = false; + setKey(navigatorKey); + + if (!getIsInitial()) { +@@ -551,14 +549,10 @@ export default function useNavigationBuilder< + + return () => { + // We need to clean up state for this navigator on unmount +- // We do it in a timeout because we need to detect if another navigator mounted in the meantime +- // For example, if another navigator has started rendering, we should skip cleanup +- // Otherwise, our cleanup step will cleanup state for the other navigator and re-initialize it +- setTimeout(() => { +- if (getCurrentState() !== undefined && getKey() === navigatorKey) { +- cleanUpState(); +- } +- }, 0); ++ if (getCurrentState() !== undefined && getKey() === navigatorKey) { ++ setCurrentState(undefined); ++ stateCleanedUp.current = true; ++ } + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); diff --git a/src/CONST.ts b/src/CONST.ts index 00f2245a55c0..8ecdadefc4e9 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -369,7 +369,6 @@ const CONST = { WORKSPACE_FEEDS: 'workspaceFeeds', NETSUITE_USA_TAX: 'netsuiteUsaTax', INTACCT_ON_NEW_EXPENSIFY: 'intacctOnNewExpensify', - COMMENT_LINKING: 'commentLinking', }, BUTTON_STATES: { DEFAULT: 'default', @@ -791,6 +790,7 @@ const CONST = { UPDATE_TIME_ENABLED: 'POLICYCHANGELOG_UPDATE_TIME_ENABLED', UPDATE_TIME_RATE: 'POLICYCHANGELOG_UPDATE_TIME_RATE', LEAVE_POLICY: 'POLICYCHANGELOG_LEAVE_POLICY', + CORPORATE_UPGRADE: 'POLICYCHANGELOG_CORPORATE_UPGRADE', }, ROOM_CHANGE_LOG: { INVITE_TO_ROOM: 'INVITETOROOM', @@ -1373,10 +1373,35 @@ const CONST = { PROVINCIAL_TAX_POSTING_ACCOUNT: 'provincialTaxPostingAccount', ALLOW_FOREIGN_CURRENCY: 'allowForeignCurrency', EXPORT_TO_NEXT_OPEN_PERIOD: 'exportToNextOpenPeriod', - IMPORT_FIELDS: ['departments', 'classes', 'locations', 'customers', 'jobs'], + IMPORT_FIELDS: ['departments', 'classes', 'locations'], + AUTO_SYNC: 'autoSync', + REIMBURSEMENT_ACCOUNT_ID: 'reimbursementAccountID', + COLLECTION_ACCOUNT: 'collectionAccount', + AUTO_CREATE_ENTITIES: 'autoCreateEntities', + APPROVAL_ACCOUNT: 'approvalAccount', + CUSTOM_FORM_ID_OPTIONS: 'customFormIDOptions', + TOKEN_INPUT_STEP_NAMES: ['1', '2,', '3', '4', '5'], + TOKEN_INPUT_STEP_KEYS: { + 0: 'installBundle', + 1: 'enableTokenAuthentication', + 2: 'enableSoapServices', + 3: 'createAccessToken', + 4: 'enterCredentials', + }, IMPORT_CUSTOM_FIELDS: ['customSegments', 'customLists'], SYNC_OPTIONS: { + SYNC_REIMBURSED_REPORTS: 'syncReimbursedReports', + SYNC_PEOPLE: 'syncPeople', + ENABLE_NEW_CATEGORIES: 'enableNewCategories', + EXPORT_REPORTS_TO: 'exportReportsTo', + EXPORT_VENDOR_BILLS_TO: 'exportVendorBillsTo', + EXPORT_JOURNALS_TO: 'exportJournalsTo', SYNC_TAX: 'syncTax', + CROSS_SUBSIDIARY_CUSTOMERS: 'crossSubsidiaryCustomers', + CUSTOMER_MAPPINGS: { + CUSTOMERS: 'customers', + JOBS: 'jobs', + }, }, }, @@ -1392,6 +1417,12 @@ const CONST = { JOURNAL_ENTRY: 'JOURNAL_ENTRY', }, + NETSUITE_MAP_EXPORT_DESTINATION: { + EXPENSE_REPORT: 'expenseReport', + VENDOR_BILL: 'vendorBill', + JOURNAL_ENTRY: 'journalEntry', + }, + NETSUITE_INVOICE_ITEM_PREFERENCE: { CREATE: 'create', SELECT: 'select', @@ -1407,6 +1438,27 @@ const CONST = { NON_REIMBURSABLE: 'nonreimbursable', }, + NETSUITE_REPORTS_APPROVAL_LEVEL: { + REPORTS_APPROVED_NONE: 'REPORTS_APPROVED_NONE', + REPORTS_SUPERVISOR_APPROVED: 'REPORTS_SUPERVISOR_APPROVED', + REPORTS_ACCOUNTING_APPROVED: 'REPORTS_ACCOUNTING_APPROVED', + REPORTS_APPROVED_BOTH: 'REPORTS_APPROVED_BOTH', + }, + + NETSUITE_VENDOR_BILLS_APPROVAL_LEVEL: { + VENDOR_BILLS_APPROVED_NONE: 'VENDOR_BILLS_APPROVED_NONE', + VENDOR_BILLS_APPROVAL_PENDING: 'VENDOR_BILLS_APPROVAL_PENDING', + VENDOR_BILLS_APPROVED: 'VENDOR_BILLS_APPROVED', + }, + + NETSUITE_JOURNALS_APPROVAL_LEVEL: { + JOURNALS_APPROVED_NONE: 'JOURNALS_APPROVED_NONE', + JOURNALS_APPROVAL_PENDING: 'JOURNALS_APPROVAL_PENDING', + JOURNALS_APPROVED: 'JOURNALS_APPROVED', + }, + + NETSUITE_APPROVAL_ACCOUNT_DEFAULT: 'APPROVAL_ACCOUNT_DEFAULT', + /** * Countries where tax setting is permitted (Strings are in the format of Netsuite's Country type/enum) * @@ -1981,6 +2033,9 @@ const CONST = { PAID: 'paid', ADMIN: 'admin', }, + DEFAULT_MAX_EXPENSE_AGE: 90, + DEFAULT_MAX_EXPENSE_AMOUNT: 200000, + DEFAULT_MAX_AMOUNT_NO_RECEIPT: 2500, }, CUSTOM_UNITS: { @@ -3875,6 +3930,7 @@ const CONST = { TAX_REQUIRED: 'taxRequired', HOLD: 'hold', }, + REVIEW_DUPLICATES_ORDER: ['merchant', 'category', 'tag', 'description', 'taxCode', 'billable', 'reimbursable'], /** Context menu types */ CONTEXT_MENU_TYPES: { @@ -5035,10 +5091,12 @@ const CONST = { SUBSCRIPTION_SIZE_LIMIT: 20000, + PAGINATION_START_ID: '-1', + PAGINATION_END_ID: '-2', + PAYMENT_CARD_CURRENCY: { USD: 'USD', AUD: 'AUD', - GBP: 'GBP', NZD: 'NZD', }, @@ -5069,7 +5127,16 @@ const CONST = { }, EXCLUDE_FROM_LAST_VISITED_PATH: [SCREENS.NOT_FOUND, SCREENS.SAML_SIGN_IN, SCREENS.VALIDATE_LOGIN] as string[], - + UPGRADE_FEATURE_INTRO_MAPPING: [ + { + id: 'reportFields', + alias: 'report-fields', + name: 'Report Fields', + title: 'workspace.upgrade.reportFields.title', + description: 'workspace.upgrade.reportFields.description', + icon: 'Pencil', + }, + ], REPORT_FIELD_TYPES: { TEXT: 'text', DATE: 'date', diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 709347fa71cd..906f8ef7095e 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -405,6 +405,7 @@ const ONYXKEYS = { REPORT_METADATA: 'reportMetadata_', REPORT_ACTIONS: 'reportActions_', REPORT_ACTIONS_DRAFTS: 'reportActionsDrafts_', + REPORT_ACTIONS_PAGES: 'reportActionsPages_', REPORT_ACTIONS_REACTIONS: 'reportActionsReactions_', REPORT_DRAFT_COMMENT: 'reportDraftComment_', REPORT_IS_COMPOSER_FULL_SIZE: 'reportIsComposerFullSize_', @@ -559,6 +560,8 @@ const ONYXKEYS = { ISSUE_NEW_EXPENSIFY_CARD_FORM_DRAFT: 'issueNewExpensifyCardFormDraft', SAGE_INTACCT_CREDENTIALS_FORM: 'sageIntacctCredentialsForm', SAGE_INTACCT_CREDENTIALS_FORM_DRAFT: 'sageIntacctCredentialsFormDraft', + NETSUITE_TOKEN_INPUT_FORM: 'netsuiteTokenInputForm', + NETSUITE_TOKEN_INPUT_FORM_DRAFT: 'netsuiteTokenInputFormDraft', }, } as const; @@ -622,6 +625,7 @@ type OnyxFormValuesMapping = { [ONYXKEYS.FORMS.SUBSCRIPTION_SIZE_FORM]: FormTypes.SubscriptionSizeForm; [ONYXKEYS.FORMS.ISSUE_NEW_EXPENSIFY_CARD_FORM]: FormTypes.IssueNewExpensifyCardForm; [ONYXKEYS.FORMS.SAGE_INTACCT_CREDENTIALS_FORM]: FormTypes.SageIntactCredentialsForm; + [ONYXKEYS.FORMS.NETSUITE_TOKEN_INPUT_FORM]: FormTypes.NetSuiteTokenInputForm; }; type OnyxFormDraftValuesMapping = { @@ -646,6 +650,7 @@ type OnyxCollectionValuesMapping = { [ONYXKEYS.COLLECTION.REPORT_METADATA]: OnyxTypes.ReportMetadata; [ONYXKEYS.COLLECTION.REPORT_ACTIONS]: OnyxTypes.ReportActions; [ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS]: OnyxTypes.ReportActionsDrafts; + [ONYXKEYS.COLLECTION.REPORT_ACTIONS_PAGES]: OnyxTypes.Pages; [ONYXKEYS.COLLECTION.REPORT_ACTIONS_REACTIONS]: OnyxTypes.ReportActionReactions; [ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT]: string; [ONYXKEYS.COLLECTION.REPORT_IS_COMPOSER_FULL_SIZE]: boolean; @@ -808,6 +813,7 @@ type OnyxFormDraftKey = keyof OnyxFormDraftValuesMapping; type OnyxValueKey = keyof OnyxValuesMapping; type OnyxKey = OnyxValueKey | OnyxCollectionKey | OnyxFormKey | OnyxFormDraftKey; +type OnyxPagesKey = typeof ONYXKEYS.COLLECTION.REPORT_ACTIONS_PAGES; type MissingOnyxKeysError = `Error: Types don't match, OnyxKey type is missing: ${Exclude}`; /** If this type errors, it means that the `OnyxKey` type is missing some keys. */ @@ -815,4 +821,4 @@ type MissingOnyxKeysError = `Error: Types don't match, OnyxKey type is missing: type AssertOnyxKeys = AssertTypesEqual; export default ONYXKEYS; -export type {OnyxCollectionKey, OnyxCollectionValuesMapping, OnyxFormDraftKey, OnyxFormKey, OnyxFormValuesMapping, OnyxKey, OnyxValueKey, OnyxValues}; +export type {OnyxCollectionKey, OnyxCollectionValuesMapping, OnyxFormDraftKey, OnyxFormKey, OnyxFormValuesMapping, OnyxKey, OnyxPagesKey, OnyxValueKey, OnyxValues}; diff --git a/src/ROUTES.ts b/src/ROUTES.ts index a70d6e7502ae..2347bd4f93f4 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -1,4 +1,4 @@ -import type {ValueOf} from 'type-fest'; +import type {TupleToUnion, ValueOf} from 'type-fest'; import type CONST from './CONST'; import type {IOUAction, IOUType} from './CONST'; import type {IOURequestType} from './libs/actions/IOU'; @@ -678,6 +678,10 @@ const ROUTES = { route: 'settings/workspaces/:policyID/categories/:categoryName', getRoute: (policyID: string, categoryName: string) => `settings/workspaces/${policyID}/categories/${encodeURIComponent(categoryName)}` as const, }, + WORKSPACE_UPGRADE: { + route: 'settings/workspaces/:policyID/upgrade/:featureName', + getRoute: (policyID: string, featureName: string) => `settings/workspaces/${policyID}/upgrade/${encodeURIComponent(featureName)}` as const, + }, WORKSPACE_CATEGORIES_SETTINGS: { route: 'settings/workspaces/:policyID/categories/settings', getRoute: (policyID: string) => `settings/workspaces/${policyID}/categories/settings` as const, @@ -783,6 +787,10 @@ const ROUTES = { route: 'settings/workspaces/:policyID/reportFields', getRoute: (policyID: string) => `settings/workspaces/${policyID}/reportFields` as const, }, + WORKSPACE_REPORT_FIELD_SETTINGS: { + route: 'settings/workspaces/:policyID/reportField/:reportFieldKey', + getRoute: (policyID: string, reportFieldKey: string) => `settings/workspaces/${policyID}/reportField/${encodeURIComponent(reportFieldKey)}` as const, + }, WORKSPACE_CREATE_REPORT_FIELD: { route: 'settings/workspaces/:policyID/reportFields/new', getRoute: (policyID: string) => `settings/workspaces/${policyID}/reportFields/new` as const, @@ -866,6 +874,34 @@ const ROUTES = { route: 'r/:threadReportID/duplicates/review', getRoute: (threadReportID: string) => `r/${threadReportID}/duplicates/review` as const, }, + TRANSACTION_DUPLICATE_REVIEW_MERCHANT_PAGE: { + route: 'r/:threadReportID/duplicates/review/merchant', + getRoute: (threadReportID: string) => `r/${threadReportID}/duplicates/review/merchant` as const, + }, + TRANSACTION_DUPLICATE_REVIEW_CATEGORY_PAGE: { + route: 'r/:threadReportID/duplicates/review/category', + getRoute: (threadReportID: string) => `r/${threadReportID}/duplicates/review/category` as const, + }, + TRANSACTION_DUPLICATE_REVIEW_TAG_PAGE: { + route: 'r/:threadReportID/duplicates/review/tag', + getRoute: (threadReportID: string) => `r/${threadReportID}/duplicates/review/tag` as const, + }, + TRANSACTION_DUPLICATE_REVIEW_TAX_CODE_PAGE: { + route: 'r/:threadReportID/duplicates/review/tax-code', + getRoute: (threadReportID: string) => `r/${threadReportID}/duplicates/review/tax-code` as const, + }, + TRANSACTION_DUPLICATE_REVIEW_DESCRIPTION_PAGE: { + route: 'r/:threadReportID/duplicates/confirm', + getRoute: (threadReportID: string) => `r/${threadReportID}/duplicates/confirm` as const, + }, + TRANSACTION_DUPLICATE_REVIEW_REIMBURSABLE_PAGE: { + route: 'r/:threadReportID/duplicates/review/reimbursable', + getRoute: (threadReportID: string) => `r/${threadReportID}/duplicates/review/reimbursable` as const, + }, + TRANSACTION_DUPLICATE_REVIEW_BILLABLE_PAGE: { + route: 'r/:threadReportID/duplicates/review/billable', + getRoute: (threadReportID: string) => `r/${threadReportID}/duplicates/review/billable` as const, + }, POLICY_ACCOUNTING_XERO_IMPORT: { route: 'settings/workspaces/:policyID/accounting/xero/import', @@ -960,10 +996,27 @@ const ROUTES = { route: 'settings/workspaces/:policyID/accounting/netsuite/subsidiary-selector', getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/netsuite/subsidiary-selector` as const, }, + POLICY_ACCOUNTING_NETSUITE_TOKEN_INPUT: { + route: 'settings/workspaces/:policyID/accounting/netsuite/token-input', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/netsuite/token-input` as const, + }, POLICY_ACCOUNTING_NETSUITE_IMPORT: { route: 'settings/workspaces/:policyID/accounting/netsuite/import', getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/netsuite/import` as const, }, + POLICY_ACCOUNTING_NETSUITE_IMPORT_MAPPING: { + route: 'settings/workspaces/:policyID/accounting/netsuite/import/mapping/:importField', + getRoute: (policyID: string, importField: TupleToUnion) => + `settings/workspaces/${policyID}/accounting/netsuite/import/mapping/${importField}` as const, + }, + POLICY_ACCOUNTING_NETSUITE_IMPORT_CUSTOMERS_OR_PROJECTS: { + route: 'settings/workspaces/:policyID/accounting/netsuite/import/customer-projects', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/netsuite/import/customer-projects` as const, + }, + POLICY_ACCOUNTING_NETSUITE_IMPORT_CUSTOMERS_OR_PROJECTS_SELECT: { + route: 'settings/workspaces/:policyID/accounting/netsuite/import/customer-projects/select', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/netsuite/import/customer-projects/select` as const, + }, POLICY_ACCOUNTING_NETSUITE_EXPORT: { route: 'settings/workspaces/:policyID/connections/netsuite/export/', getRoute: (policyID: string) => `settings/workspaces/${policyID}/connections/netsuite/export/` as const, @@ -1021,6 +1074,10 @@ const ROUTES = { route: 'settings/workspaces/:policyID/connections/netsuite/export/provincial-tax-posting-account/select', getRoute: (policyID: string) => `settings/workspaces/${policyID}/connections/netsuite/export/provincial-tax-posting-account/select` as const, }, + POLICY_ACCOUNTING_NETSUITE_ADVANCED: { + route: 'settings/workspaces/:policyID/connections/netsuite/advanced/', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/connections/netsuite/advanced/` as const, + }, POLICY_ACCOUNTING_SAGE_INTACCT_PREREQUISITES: { route: 'settings/workspaces/:policyID/accounting/sage-intacct/prerequisites', getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/sage-intacct/prerequisites` as const, diff --git a/src/SCREENS.ts b/src/SCREENS.ts index e12ccfdab072..8d077d635fcc 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -186,6 +186,13 @@ const SCREENS = { TRANSACTION_DUPLICATE: { REVIEW: 'Transaction_Duplicate_Review', + MERCHANT: 'Transaction_Duplicate_Merchant', + CATEGORY: 'Transaction_Duplicate_Category', + TAG: 'Transaction_Duplicate_Tag', + DESCRIPTION: 'Transaction_Duplicate_Description', + TAX_CODE: 'Transaction_Duplicate_Tax_Code', + REIMBURSABLE: 'Transaction_Duplicate_Reimburable', + BILLABLE: 'Transaction_Duplicate_Billable', }, IOU_SEND: { @@ -272,6 +279,10 @@ const SCREENS = { XERO_EXPORT_PREFERRED_EXPORTER_SELECT: 'Workspace_Accounting_Xero_Export_Preferred_Exporter_Select', XERO_BILL_PAYMENT_ACCOUNT_SELECTOR: 'Policy_Accounting_Xero_Bill_Payment_Account_Selector', XERO_EXPORT_BANK_ACCOUNT_SELECT: 'Policy_Accounting_Xero_Export_Bank_Account_Select', + NETSUITE_IMPORT_MAPPING: 'Policy_Accounting_NetSuite_Import_Mapping', + NETSUITE_IMPORT_CUSTOMERS_OR_PROJECTS: 'Policy_Accounting_NetSuite_Import_CustomersOrProjects', + NETSUITE_IMPORT_CUSTOMERS_OR_PROJECTS_SELECT: 'Policy_Accounting_NetSuite_Import_CustomersOrProjects_Select', + NETSUITE_TOKEN_INPUT: 'Policy_Accounting_NetSuite_Token_Input', NETSUITE_SUBSIDIARY_SELECTOR: 'Policy_Accounting_NetSuite_Subsidiary_Selector', NETSUITE_IMPORT: 'Policy_Accounting_NetSuite_Import', NETSUITE_EXPORT: 'Policy_Accounting_NetSuite_Export', @@ -287,6 +298,7 @@ const SCREENS = { NETSUITE_INVOICE_ITEM_SELECT: 'Policy_Accounting_NetSuite_Invoice_Item_Select', NETSUITE_TAX_POSTING_ACCOUNT_SELECT: 'Policy_Accounting_NetSuite_Tax_Posting_Account_Select', NETSUITE_PROVINCIAL_TAX_POSTING_ACCOUNT_SELECT: 'Policy_Accounting_NetSuite_Provincial_Tax_Posting_Account_Select', + NETSUITE_ADVANCED: 'Policy_Accounting_NetSuite_Advanced', SAGE_INTACCT_PREREQUISITES: 'Policy_Accounting_Sage_Intacct_Prerequisites', ENTER_SAGE_INTACCT_CREDENTIALS: 'Policy_Enter_Sage_Intacct_Credentials', EXISTING_SAGE_INTACCT_CONNECTIONS: 'Policy_Existing_Sage_Intacct_Connections', @@ -313,6 +325,7 @@ const SCREENS = { TAG_EDIT: 'Tag_Edit', TAXES: 'Workspace_Taxes', REPORT_FIELDS: 'Workspace_ReportFields', + REPORT_FIELD_SETTINGS: 'Workspace_ReportField_Settings', REPORT_FIELDS_CREATE: 'Workspace_ReportFields_Create', REPORT_FIELDS_LIST_VALUES: 'Workspace_ReportFields_ListValues', REPORT_FIELDS_ADD_VALUE: 'Workspace_ReportFields_AddValue', @@ -355,6 +368,7 @@ const SCREENS = { DISTANCE_RATE_EDIT: 'Distance_Rate_Edit', DISTANCE_RATE_TAX_RECLAIMABLE_ON_EDIT: 'Distance_Rate_Tax_Reclaimable_On_Edit', DISTANCE_RATE_TAX_RATE_EDIT: 'Distance_Rate_Tax_Rate_Edit', + UPGRADE: 'Workspace_Upgrade', }, EDIT_REQUEST: { diff --git a/src/components/AddressForm.tsx b/src/components/AddressForm.tsx index 27822fb390a6..7ca4cc3273ca 100644 --- a/src/components/AddressForm.tsx +++ b/src/components/AddressForm.tsx @@ -182,6 +182,7 @@ function AddressForm({ InputComponent={CountrySelector} inputID={INPUT_IDS.COUNTRY} value={country} + onValueChange={onAddressChanged} shouldSaveDraft={shouldSaveDraft} /> diff --git a/src/components/AttachmentModal.tsx b/src/components/AttachmentModal.tsx index df027ed6edb4..450a49403215 100644 --- a/src/components/AttachmentModal.tsx +++ b/src/components/AttachmentModal.tsx @@ -320,7 +320,7 @@ function AttachmentModal({ } let fileObject = data; if ('getAsFile' in data && typeof data.getAsFile === 'function') { - fileObject = data.getAsFile(); + fileObject = data.getAsFile() as FileObject; } if (!fileObject) { return; diff --git a/src/components/Attachments/AttachmentCarousel/index.tsx b/src/components/Attachments/AttachmentCarousel/index.tsx index 8f4a4446df99..36abe1e2e5ed 100644 --- a/src/components/Attachments/AttachmentCarousel/index.tsx +++ b/src/components/Attachments/AttachmentCarousel/index.tsx @@ -135,7 +135,7 @@ function AttachmentCarousel({report, reportActions, parentReportActions, source, return; } - const item: Attachment = entry.item; + const item = entry.item as Attachment; if (entry.index !== null) { setPage(entry.index); setActiveSource(item.source); diff --git a/src/components/Composer/index.tsx b/src/components/Composer/index.tsx index f4a5174c2602..eb7091cd958c 100755 --- a/src/components/Composer/index.tsx +++ b/src/components/Composer/index.tsx @@ -251,7 +251,7 @@ function Composer( }, []); useEffect(() => { - const scrollingListener = DeviceEventEmitter.addListener(CONST.EVENTS.SCROLLING, (scrolling) => { + const scrollingListener = DeviceEventEmitter.addListener(CONST.EVENTS.SCROLLING, (scrolling: boolean) => { isReportFlatListScrolling.current = scrolling; }); diff --git a/src/components/ConfirmationPage.tsx b/src/components/ConfirmationPage.tsx index d1a73b7933fe..883e7261f386 100644 --- a/src/components/ConfirmationPage.tsx +++ b/src/components/ConfirmationPage.tsx @@ -17,7 +17,7 @@ type ConfirmationPageProps = { heading: string; /** Description of the confirmation page */ - description: string; + description: React.ReactNode; /** The text for the button label */ buttonText?: string; diff --git a/src/components/ConnectToNetSuiteButton/index.tsx b/src/components/ConnectToNetSuiteButton/index.tsx index fc948503a127..a0cd36671117 100644 --- a/src/components/ConnectToNetSuiteButton/index.tsx +++ b/src/components/ConnectToNetSuiteButton/index.tsx @@ -26,8 +26,7 @@ function ConnectToNetSuiteButton({policyID, shouldDisconnectIntegrationBeforeCon return; } - // TODO: Will be updated to new token input page - Navigation.navigate(ROUTES.POLICY_ACCOUNTING_NETSUITE_SUBSIDIARY_SELECTOR.getRoute(policyID)); + Navigation.navigate(ROUTES.POLICY_ACCOUNTING_NETSUITE_TOKEN_INPUT.getRoute(policyID)); }} text={translate('workspace.accounting.setup')} style={styles.justifyContentCenter} @@ -39,8 +38,7 @@ function ConnectToNetSuiteButton({policyID, shouldDisconnectIntegrationBeforeCon onConfirm={() => { removePolicyConnection(policyID, integrationToDisconnect); - // TODO: Will be updated to new token input page - Navigation.navigate(ROUTES.POLICY_ACCOUNTING_NETSUITE_SUBSIDIARY_SELECTOR.getRoute(policyID)); + Navigation.navigate(ROUTES.POLICY_ACCOUNTING_NETSUITE_TOKEN_INPUT.getRoute(policyID)); setIsDisconnectModalOpen(false); }} integrationToConnect={CONST.POLICY.CONNECTIONS.NAME.NETSUITE} diff --git a/src/components/ConnectionLayout.tsx b/src/components/ConnectionLayout.tsx index adb607c8e98b..dc8638f018d4 100644 --- a/src/components/ConnectionLayout.tsx +++ b/src/components/ConnectionLayout.tsx @@ -9,7 +9,6 @@ import * as PolicyUtils from '@libs/PolicyUtils'; import type {AccessVariant} from '@pages/workspace/AccessOrNotFoundWrapper'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; import type {TranslationPaths} from '@src/languages/types'; -import type {Route} from '@src/ROUTES'; import type {ConnectionName, PolicyFeatureName} from '@src/types/onyx/Policy'; import HeaderWithBackButton from './HeaderWithBackButton'; import ScreenWrapper from './ScreenWrapper'; @@ -20,9 +19,6 @@ type ConnectionLayoutProps = { /** Used to set the testID for tests */ displayName: string; - /* The route on back button press */ - onBackButtonPressRoute?: Route; - /** Header title to be translated for the connection component */ headerTitle?: TranslationPaths; @@ -64,6 +60,12 @@ type ConnectionLayoutProps = { /** Name of the current connection */ connectionName: ConnectionName; + + /** Block the screen when the connection is not empty */ + reverseConnectionEmptyCheck?: boolean; + + /** Handler for back button press */ + onBackButtonPress?: () => void; }; type ConnectionLayoutContentProps = Pick; @@ -81,7 +83,6 @@ function ConnectionLayoutContent({title, titleStyle, children, titleAlreadyTrans function ConnectionLayout({ displayName, - onBackButtonPressRoute, headerTitle, children, title, @@ -96,6 +97,8 @@ function ConnectionLayout({ shouldUseScrollView = true, headerTitleAlreadyTranslated, titleAlreadyTranslated, + reverseConnectionEmptyCheck = false, + onBackButtonPress = () => Navigation.goBack(), }: ConnectionLayoutProps) { const {translate} = useLocalize(); @@ -120,7 +123,7 @@ function ConnectionLayout({ policyID={policyID} accessVariants={accessVariants} featureName={featureName} - shouldBeBlocked={isConnectionEmpty} + shouldBeBlocked={reverseConnectionEmptyCheck ? !isConnectionEmpty : isConnectionEmpty} > Navigation.goBack(onBackButtonPressRoute)} + onBackButtonPress={onBackButtonPress} /> {shouldUseScrollView ? ( {renderSelectionContent} diff --git a/src/components/CountrySelector.tsx b/src/components/CountrySelector.tsx index 62fdc85687e1..d4737701fcf4 100644 --- a/src/components/CountrySelector.tsx +++ b/src/components/CountrySelector.tsx @@ -2,6 +2,7 @@ import {useIsFocused} from '@react-navigation/native'; import React, {forwardRef, useEffect, useRef} from 'react'; import type {ForwardedRef} from 'react'; import type {View} from 'react-native'; +import useGeographicalStateAndCountryFromRoute from '@hooks/useGeographicalStateAndCountryFromRoute'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import Navigation from '@libs/Navigation/Navigation'; @@ -31,6 +32,7 @@ type CountrySelectorProps = { function CountrySelector({errorText = '', value: countryCode, onInputChange = () => {}, onBlur}: CountrySelectorProps, ref: ForwardedRef) { const styles = useThemeStyles(); const {translate} = useLocalize(); + const {country: countryFromUrl} = useGeographicalStateAndCountryFromRoute(); const title = countryCode ? translate(`allCountries.${countryCode}`) : ''; const countryTitleDescStyle = title.length === 0 ? styles.textNormal : null; @@ -38,18 +40,30 @@ function CountrySelector({errorText = '', value: countryCode, onInputChange = () const didOpenContrySelector = useRef(false); const isFocused = useIsFocused(); useEffect(() => { - if (!isFocused || !didOpenContrySelector.current) { + // Check if the country selector was opened and no value was selected, triggering onBlur to display an error + if (isFocused && didOpenContrySelector.current) { + didOpenContrySelector.current = false; + if (!countryFromUrl) { + onBlur?.(); + } + } + + // If no country is selected from the URL, exit the effect early to avoid further processing. + if (!countryFromUrl) { return; } - didOpenContrySelector.current = false; - onBlur?.(); - }, [isFocused, onBlur]); - useEffect(() => { - // This will cause the form to revalidate and remove any error related to country name - onInputChange(countryCode); + // If a country is selected, invoke `onInputChange` to update the form and clear any validation errors related to the country selection. + if (onInputChange) { + onInputChange(countryFromUrl); + } + + // Clears the `country` parameter from the URL to ensure the component country is driven by the parent component rather than URL parameters. + // This helps prevent issues where the component might not update correctly if the country is controlled by both the parent and the URL. + Navigation.setParams({country: undefined}); + // eslint-disable-next-line react-hooks/exhaustive-deps - }, [countryCode]); + }, [countryFromUrl, isFocused, onBlur]); return ( ({maintainVisibleContentPosition, horizontal = false return horizontal ? getScrollableNode(scrollRef.current)?.scrollLeft ?? 0 : getScrollableNode(scrollRef.current)?.scrollTop ?? 0; }, [horizontal]); - // eslint-disable-next-line @typescript-eslint/no-unsafe-return const getContentView = useCallback(() => getScrollableNode(scrollRef.current)?.childNodes[0], []); const scrollToOffset = useCallback( diff --git a/src/components/Hoverable/ActiveHoverable.tsx b/src/components/Hoverable/ActiveHoverable.tsx index abd48d432953..fd3d4f3d19e8 100644 --- a/src/components/Hoverable/ActiveHoverable.tsx +++ b/src/components/Hoverable/ActiveHoverable.tsx @@ -48,7 +48,7 @@ function ActiveHoverable({onHoverIn, onHoverOut, shouldHandleScroll, shouldFreez return; } - const scrollingListener = DeviceEventEmitter.addListener(CONST.EVENTS.SCROLLING, (scrolling) => { + const scrollingListener = DeviceEventEmitter.addListener(CONST.EVENTS.SCROLLING, (scrolling: boolean) => { isScrollingRef.current = scrolling; if (!isScrollingRef.current) { setIsHovered(isHoveredRef.current); @@ -102,7 +102,7 @@ function ActiveHoverable({onHoverIn, onHoverOut, shouldHandleScroll, shouldFreez const child = useMemo(() => getReturnValue(children, !isScrollingRef.current && isHovered), [children, isHovered]); - const {onMouseEnter, onMouseLeave, onMouseMove, onBlur}: OnMouseEvents = child.props; + const {onMouseEnter, onMouseLeave, onMouseMove, onBlur} = child.props as OnMouseEvents; const hoverAndForwardOnMouseEnter = useCallback( (e: MouseEvent) => { diff --git a/src/components/IFrame.tsx b/src/components/IFrame.tsx index 05da3a1edb9c..f492df0f3866 100644 --- a/src/components/IFrame.tsx +++ b/src/components/IFrame.tsx @@ -17,7 +17,7 @@ function getNewDotURL(url: string): string { let params: Record; try { - params = JSON.parse(paramString); + params = JSON.parse(paramString) as Record; } catch { params = {}; } diff --git a/src/components/Icon/Illustrations.ts b/src/components/Icon/Illustrations.ts index e699badc43ec..bd0824372799 100644 --- a/src/components/Icon/Illustrations.ts +++ b/src/components/Icon/Illustrations.ts @@ -1,3 +1,4 @@ +import ExpensifyCardIllustration from '@assets/images/expensifyCard/cardIllustration.svg'; import Abracadabra from '@assets/images/product-illustrations/abracadabra.svg'; import BankArrowPink from '@assets/images/product-illustrations/bank-arrow--pink.svg'; import BankMouseGreen from '@assets/images/product-illustrations/bank-mouse--green.svg'; @@ -176,6 +177,7 @@ export { Binoculars, CompanyCard, ReceiptUpload, + ExpensifyCardIllustration, SplitBill, PiggyBank, Accounting, diff --git a/src/components/InteractiveStepSubHeader.tsx b/src/components/InteractiveStepSubHeader.tsx index 20b3f6bc79a4..d8899a317df5 100644 --- a/src/components/InteractiveStepSubHeader.tsx +++ b/src/components/InteractiveStepSubHeader.tsx @@ -25,6 +25,9 @@ type InteractiveStepSubHeaderProps = { type InteractiveStepSubHeaderHandle = { /** Move to the next step */ moveNext: () => void; + + /** Move to the previous step */ + movePrevious: () => void; }; const MIN_AMOUNT_FOR_EXPANDING = 3; @@ -45,6 +48,9 @@ function InteractiveStepSubHeader({stepNames, startStepIndex = 0, onStepSelected moveNext: () => { setCurrentStep((actualStep) => actualStep + 1); }, + movePrevious: () => { + setCurrentStep((actualStep) => actualStep - 1); + }, }), [], ); diff --git a/src/components/LottieAnimations/index.tsx b/src/components/LottieAnimations/index.tsx index 477ce02cd740..afbc9cd56e28 100644 --- a/src/components/LottieAnimations/index.tsx +++ b/src/components/LottieAnimations/index.tsx @@ -1,80 +1,81 @@ +import type {LottieViewProps} from 'lottie-react-native'; import colors from '@styles/theme/colors'; import variables from '@styles/variables'; import type DotLottieAnimation from './types'; const DotLottieAnimations = { Abracadabra: { - file: require('@assets/animations/Abracadabra.lottie'), + file: require('@assets/animations/Abracadabra.lottie'), w: 375, h: 400, }, FastMoney: { - file: require('@assets/animations/FastMoney.lottie'), + file: require('@assets/animations/FastMoney.lottie'), w: 375, h: 240, }, Fireworks: { - file: require('@assets/animations/Fireworks.lottie'), + file: require('@assets/animations/Fireworks.lottie'), w: 360, h: 360, }, Hands: { - file: require('@assets/animations/Hands.lottie'), + file: require('@assets/animations/Hands.lottie'), w: 375, h: 375, }, PreferencesDJ: { - file: require('@assets/animations/PreferencesDJ.lottie'), + file: require('@assets/animations/PreferencesDJ.lottie'), w: 375, h: 240, backgroundColor: colors.blue500, }, ReviewingBankInfo: { - file: require('@assets/animations/ReviewingBankInfo.lottie'), + file: require('@assets/animations/ReviewingBankInfo.lottie'), w: 280, h: 280, }, WorkspacePlanet: { - file: require('@assets/animations/WorkspacePlanet.lottie'), + file: require('@assets/animations/WorkspacePlanet.lottie'), w: 375, h: 240, backgroundColor: colors.pink800, }, SaveTheWorld: { - file: require('@assets/animations/SaveTheWorld.lottie'), + file: require('@assets/animations/SaveTheWorld.lottie'), w: 375, h: 240, }, Safe: { - file: require('@assets/animations/Safe.lottie'), + file: require('@assets/animations/Safe.lottie'), w: 625, h: 400, backgroundColor: colors.ice500, }, Magician: { - file: require('@assets/animations/Magician.lottie'), + file: require('@assets/animations/Magician.lottie'), w: 853, h: 480, }, Update: { - file: require('@assets/animations/Update.lottie'), + file: require('@assets/animations/Update.lottie'), w: variables.updateAnimationW, h: variables.updateAnimationH, }, Coin: { - file: require('@assets/animations/Coin.lottie'), + file: require('@assets/animations/Coin.lottie'), w: 375, h: 240, backgroundColor: colors.yellow600, }, Desk: { - file: require('@assets/animations/Desk.lottie'), + file: require('@assets/animations/Desk.lottie'), w: 200, h: 120, backgroundColor: colors.blue700, }, Plane: { - file: require('@assets/animations/Plane.lottie'), + file: require('@assets/animations/Plane.lottie'), w: 180, h: 200, }, diff --git a/src/components/MagicCodeInput.tsx b/src/components/MagicCodeInput.tsx index 6239243cb5ab..956f3ffe5e02 100644 --- a/src/components/MagicCodeInput.tsx +++ b/src/components/MagicCodeInput.tsx @@ -298,7 +298,7 @@ function MagicCodeInput( // Fill the array with empty characters if there are no inputs. if (focusedIndex === 0 && !hasInputs) { - numbers = Array(maxLength).fill(CONST.MAGIC_CODE_EMPTY_CHAR); + numbers = Array(maxLength).fill(CONST.MAGIC_CODE_EMPTY_CHAR); // Deletes the value of the previous input and focuses on it. } else if (focusedIndex && focusedIndex !== 0) { diff --git a/src/components/MenuItem.tsx b/src/components/MenuItem.tsx index 357ef60d5161..473806aac3af 100644 --- a/src/components/MenuItem.tsx +++ b/src/components/MenuItem.tsx @@ -263,6 +263,9 @@ type MenuItemBaseProps = { /** Text to display under the main item */ furtherDetails?: string; + /** Render custom content under the main item */ + furtherDetailsComponent?: ReactElement; + /** The function that should be called when this component is LongPressed or right-clicked. */ onSecondaryInteraction?: (event: GestureResponderEvent | MouseEvent) => void; @@ -338,6 +341,7 @@ function MenuItem( iconRight = Expensicons.ArrowRight, furtherDetailsIcon, furtherDetails, + furtherDetailsComponent, description, helperText, helperTextStyle, @@ -700,6 +704,7 @@ function MenuItem( )} + {!!furtherDetailsComponent && {furtherDetailsComponent}} {titleComponent} diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index 780c8c7d2ea4..80ad2890afaa 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -8,7 +8,6 @@ import useTheme from '@hooks/useTheme'; 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'; @@ -22,7 +21,6 @@ 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'; import type IconAsset from '@src/types/utils/IconAsset'; import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; import Button from './Button'; @@ -86,28 +84,20 @@ function MoneyReportHeader({policy, report: moneyRequestReport, transactionThrea const [shouldShowHoldMenu, setShouldShowHoldMenu] = useState(false); const {translate} = useLocalize(); const {isOffline} = useNetwork(); - const {isSmallScreenWidth, windowWidth} = useWindowDimensions(); + const {isSmallScreenWidth} = useWindowDimensions(); const {reimbursableSpend} = ReportUtils.getMoneyRequestSpendBreakdown(moneyRequestReport); - const isSettled = ReportUtils.isSettled(moneyRequestReport.reportID); - const isApproved = ReportUtils.isReportApproved(moneyRequestReport); - const isClosed = ReportUtils.isClosedReport(moneyRequestReport); const isOnHold = TransactionUtils.isOnHold(transaction); - const isScanning = TransactionUtils.hasReceipt(transaction) && TransactionUtils.isReceiptBeingScanned(transaction); const isDeletedParentAction = !!requestParentReportAction && ReportActionsUtils.isDeletedAction(requestParentReportAction); - const canHoldOrUnholdRequest = !isEmptyObject(transaction) && !isSettled && !isApproved && !isDeletedParentAction && !isClosed; // Only the requestor can delete the request, admins can only edit it. const isActionOwner = typeof requestParentReportAction?.actorAccountID === 'number' && typeof session?.accountID === 'number' && requestParentReportAction.actorAccountID === session?.accountID; const canDeleteRequest = isActionOwner && ReportUtils.canAddOrDeleteTransactions(moneyRequestReport) && !isDeletedParentAction; - const isPolicyAdmin = policy?.role === CONST.POLICY.ROLE.ADMIN; - const isApprover = ReportUtils.isMoneyRequestReport(moneyRequestReport) && moneyRequestReport?.managerID !== null && session?.accountID === moneyRequestReport?.managerID; const [isHoldMenuVisible, setIsHoldMenuVisible] = useState(false); const [paymentType, setPaymentType] = useState(); 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); @@ -147,7 +137,7 @@ function MoneyReportHeader({policy, report: moneyRequestReport, transactionThrea const displayedAmount = ReportUtils.hasHeldExpenses(moneyRequestReport.reportID) && canAllowSettlement ? nonHeldAmount : formattedAmount; const isMoreContentShown = shouldShowNextStep || shouldShowStatusBar || (shouldShowAnyButton && shouldUseNarrowLayout); - const confirmPayment = (type?: PaymentMethodType | undefined, payAsBusiness?: boolean) => { + const confirmPayment = (type?: PaymentMethodType | undefined) => { if (!type || !chatReport) { return; } @@ -156,7 +146,7 @@ function MoneyReportHeader({policy, report: moneyRequestReport, transactionThrea if (ReportUtils.hasHeldExpenses(moneyRequestReport.reportID)) { setIsHoldMenuVisible(true); } else if (ReportUtils.isInvoiceReport(moneyRequestReport)) { - IOU.payInvoice(type, chatReport, moneyRequestReport, payAsBusiness); + IOU.payInvoice(type, chatReport, moneyRequestReport); } else { IOU.payMoneyRequest(type, chatReport, moneyRequestReport, true); } @@ -198,22 +188,6 @@ function MoneyReportHeader({policy, report: moneyRequestReport, transactionThrea TransactionActions.markAsCash(iouTransactionID, reportID); }, [requestParentReportAction, transactionThreadReport?.reportID]); - const changeMoneyRequestStatus = () => { - if (!transactionThreadReport) { - return; - } - const iouTransactionID = ReportActionsUtils.isMoneyRequestAction(requestParentReportAction) - ? ReportActionsUtils.getOriginalMessage(requestParentReportAction)?.IOUTransactionID ?? '-1' - : '-1'; - - if (isOnHold) { - IOU.unholdRequest(iouTransactionID, transactionThreadReport.reportID); - } else { - const activeRoute = encodeURIComponent(Navigation.getActiveRouteWithoutParams()); - Navigation.navigate(ROUTES.MONEY_REQUEST_HOLD_REASON.getRoute(policy?.type ?? CONST.POLICY.TYPE.PERSONAL, iouTransactionID, transactionThreadReport.reportID, activeRoute)); - } - }; - const getStatusIcon: (src: IconAsset) => React.ReactNode = (src) => ( changeMoneyRequestStatus(), - }); - } - if (!isOnHold && (isRequestIOU || canModifyStatus) && !isScanning && !isInvoiceReport) { - threeDotsMenuItems.push({ - icon: Expensicons.Stopwatch, - text: translate('iou.hold'), - onSelected: () => changeMoneyRequestStatus(), - }); - } - } - useEffect(() => { if (isLoadingHoldUseExplained) { return; @@ -291,23 +242,6 @@ function MoneyReportHeader({policy, report: moneyRequestReport, transactionThrea IOU.dismissHoldUseExplanation(); }; - if (isPayer && isSettled && ReportUtils.isExpenseReport(moneyRequestReport)) { - threeDotsMenuItems.push({ - icon: Expensicons.Trashcan, - text: translate('iou.cancelPayment'), - onSelected: () => setIsConfirmModalVisible(true), - }); - } - - // If the report supports adding transactions to it, then it also supports deleting transactions from it. - if (canDeleteRequest && !isEmptyObject(transactionThreadReport)) { - threeDotsMenuItems.push({ - icon: Expensicons.Trashcan, - text: translate('reportActionContextMenu.deleteAction', {action: requestParentReportAction}), - onSelected: () => setIsDeleteRequestModalVisible(true), - }); - } - useEffect(() => { if (canDeleteRequest) { return; @@ -328,9 +262,6 @@ function MoneyReportHeader({policy, report: moneyRequestReport, transactionThrea onBackButtonPress={onBackButtonPress} // Shows border if no buttons or banners are showing below the header shouldShowBorderBottom={!isMoreContentShown} - shouldShowThreeDotsButton - threeDotsMenuItems={threeDotsMenuItems} - threeDotsAnchorPosition={styles.threeDotsPopoverOffsetNoCloseButton(windowWidth)} > {shouldShowSettlementButton && !shouldUseNarrowLayout && ( diff --git a/src/components/MoneyRequestConfirmationList.tsx b/src/components/MoneyRequestConfirmationList.tsx index 8bfcbbeb779e..7e6682492eb2 100755 --- a/src/components/MoneyRequestConfirmationList.tsx +++ b/src/components/MoneyRequestConfirmationList.tsx @@ -814,6 +814,7 @@ function MoneyRequestConfirmationList({ isTypeInvoice={isTypeInvoice} onToggleBillable={onToggleBillable} policy={policy} + policyTags={policyTags} policyTagLists={policyTagLists} rate={rate} receiptFilename={receiptFilename} diff --git a/src/components/MoneyRequestConfirmationListFooter.tsx b/src/components/MoneyRequestConfirmationListFooter.tsx index cda43938a18f..8dfff6466ab9 100644 --- a/src/components/MoneyRequestConfirmationListFooter.tsx +++ b/src/components/MoneyRequestConfirmationListFooter.tsx @@ -117,6 +117,9 @@ type MoneyRequestConfirmationListFooterProps = { /** The policy */ policy: OnyxEntry; + /** The policy tag lists */ + policyTags: OnyxEntry; + /** The policy tag lists */ policyTagLists: Array>; @@ -193,6 +196,7 @@ function MoneyRequestConfirmationListFooter({ isTypeInvoice, onToggleBillable, policy, + policyTags, policyTagLists, rate, receiptFilename, @@ -226,6 +230,7 @@ function MoneyRequestConfirmationListFooter({ // A flag for showing the tags field // TODO: remove the !isTypeInvoice from this condition after BE supports tags for invoices: https://github.com/Expensify/App/issues/41281 const shouldShowTags = useMemo(() => isPolicyExpenseChat && OptionsListUtils.hasEnabledTags(policyTagLists) && !isTypeInvoice, [isPolicyExpenseChat, isTypeInvoice, policyTagLists]); + const isMultilevelTags = useMemo(() => PolicyUtils.isMultiLevelTags(policyTags), [policyTags]); const senderWorkspace = useMemo(() => { const senderWorkspaceParticipant = selectedParticipants.find((participant) => participant.isSender); @@ -437,8 +442,9 @@ function MoneyRequestConfirmationListFooter({ shouldShow: shouldShowCategories, isSupplementary: action === CONST.IOU.ACTION.CATEGORIZE ? false : !isCategoryRequired, }, - ...policyTagLists.map(({name, required}, index) => { + ...policyTagLists.map(({name, required, tags}, index) => { const isTagRequired = required ?? false; + const shouldShow = shouldShowTags && (!isMultilevelTags || OptionsListUtils.hasEnabledOptions(tags)); return { item: ( ), - shouldShow: shouldShowTags, + shouldShow, isSupplementary: !isTagRequired, }; }), diff --git a/src/components/MoneyRequestHeader.tsx b/src/components/MoneyRequestHeader.tsx index d55d3cc19fe9..b30e9da50701 100644 --- a/src/components/MoneyRequestHeader.tsx +++ b/src/components/MoneyRequestHeader.tsx @@ -1,5 +1,5 @@ import type {ReactNode} from 'react'; -import React, {useCallback, useEffect, useRef, useState} from 'react'; +import React, {useCallback, useEffect, useState} from 'react'; import {View} from 'react-native'; import {useOnyx} from 'react-native-onyx'; import type {OnyxEntry} from 'react-native-onyx'; @@ -7,7 +7,6 @@ import useLocalize from '@hooks/useLocalize'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; -import * as HeaderUtils from '@libs/HeaderUtils'; import Navigation from '@libs/Navigation/Navigation'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; import * as ReportUtils from '@libs/ReportUtils'; @@ -15,15 +14,12 @@ import * as TransactionUtils from '@libs/TransactionUtils'; import variables from '@styles/variables'; import * as IOU from '@userActions/IOU'; 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 IconAsset from '@src/types/utils/IconAsset'; import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; import Button from './Button'; -import ConfirmModal from './ConfirmModal'; import HeaderWithBackButton from './HeaderWithBackButton'; import Icon from './Icon'; import * as Expensicons from './Icon/Expensicons'; @@ -56,43 +52,21 @@ function MoneyRequestHeader({report, parentReportAction, policy, shouldUseNarrow }`, ); const [transactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS); - const [session] = useOnyx(ONYXKEYS.SESSION); const [dismissedHoldUseExplanation, dismissedHoldUseExplanationResult] = useOnyx(ONYXKEYS.NVP_DISMISSED_HOLD_USE_EXPLANATION, {initialValue: true}); const isLoadingHoldUseExplained = isLoadingOnyxValue(dismissedHoldUseExplanationResult); const styles = useThemeStyles(); const theme = useTheme(); const {translate} = useLocalize(); - const [isDeleteModalVisible, setIsDeleteModalVisible] = useState(false); const [shouldShowHoldMenu, setShouldShowHoldMenu] = useState(false); const isSelfDMTrackExpenseReport = ReportUtils.isTrackExpenseReport(report) && ReportUtils.isSelfDM(parentReport); const moneyRequestReport = !isSelfDMTrackExpenseReport ? parentReport : undefined; - const isSettled = ReportUtils.isSettled(moneyRequestReport?.reportID); - const isApproved = ReportUtils.isReportApproved(moneyRequestReport); const isDraft = ReportUtils.isOpenExpenseReport(moneyRequestReport); const isOnHold = TransactionUtils.isOnHold(transaction); const isDuplicate = TransactionUtils.isDuplicate(transaction?.transactionID ?? ''); - const {isSmallScreenWidth, windowWidth} = useWindowDimensions(); + const {isSmallScreenWidth} = useWindowDimensions(); - 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; const hasAllPendingRTERViolations = TransactionUtils.allHavePendingRTERViolation([transaction?.transactionID ?? '-1']); const shouldShowMarkAsCashButton = isDraft && hasAllPendingRTERViolations; - const deleteTransaction = useCallback(() => { - if (parentReportAction) { - const iouTransactionID = ReportActionsUtils.isMoneyRequestAction(parentReportAction) ? ReportActionsUtils.getOriginalMessage(parentReportAction)?.IOUTransactionID ?? '-1' : '-1'; - if (ReportActionsUtils.isTrackExpenseAction(parentReportAction)) { - navigateBackToAfterDelete.current = IOU.deleteTrackExpense(parentReport?.reportID ?? '-1', iouTransactionID, parentReportAction, true); - } else { - navigateBackToAfterDelete.current = IOU.deleteMoneyRequest(iouTransactionID, parentReportAction, true); - } - } - - setIsDeleteModalVisible(false); - }, [parentReport?.reportID, parentReportAction, setIsDeleteModalVisible]); const markAsCash = useCallback(() => { TransactionActions.markAsCash(transaction?.transactionID ?? '-1', report.reportID); @@ -100,23 +74,6 @@ function MoneyRequestHeader({report, parentReportAction, policy, shouldUseNarrow const isScanning = TransactionUtils.hasReceipt(transaction) && TransactionUtils.isReceiptBeingScanned(transaction); - const isDeletedParentAction = ReportActionsUtils.isDeletedAction(parentReportAction); - const canHoldOrUnholdRequest = !isSettled && !isApproved && !isDeletedParentAction && !ReportUtils.isArchivedRoom(parentReport); - - // If the report supports adding transactions to it, then it also supports deleting transactions from it. - const canDeleteRequest = isActionOwner && (ReportUtils.canAddOrDeleteTransactions(moneyRequestReport) || isSelfDMTrackExpenseReport) && !isDeletedParentAction; - - const changeMoneyRequestStatus = () => { - const iouTransactionID = ReportActionsUtils.isMoneyRequestAction(parentReportAction) ? ReportActionsUtils.getOriginalMessage(parentReportAction)?.IOUTransactionID ?? '-1' : '-1'; - - if (isOnHold) { - IOU.unholdRequest(iouTransactionID, report?.reportID); - } else { - const activeRoute = encodeURIComponent(Navigation.getActiveRouteWithoutParams()); - Navigation.navigate(ROUTES.MONEY_REQUEST_HOLD_REASON.getRoute(policy?.type ?? CONST.POLICY.TYPE.PERSONAL, iouTransactionID, report?.reportID, activeRoute)); - } - }; - const getStatusIcon: (src: IconAsset) => ReactNode = (src) => ( { - if (canDeleteRequest) { - return; - } - - setIsDeleteModalVisible(false); - }, [canDeleteRequest]); - - const threeDotsMenuItems = [HeaderUtils.getPinMenuItem(report)]; - if (canHoldOrUnholdRequest) { - const isRequestIOU = parentReport?.type === 'iou'; - const isHoldCreator = ReportUtils.isHoldCreator(transaction, report?.reportID) && isRequestIOU; - const isTrackExpenseReport = ReportUtils.isTrackExpenseReport(report); - const canModifyStatus = !isTrackExpenseReport && (isPolicyAdmin || isActionOwner || isApprover); - if (isOnHold && !isDuplicate && (isHoldCreator || (!isRequestIOU && canModifyStatus))) { - threeDotsMenuItems.push({ - icon: Expensicons.Stopwatch, - text: translate('iou.unholdExpense'), - onSelected: () => changeMoneyRequestStatus(), - }); - } - if (!isOnHold && (isRequestIOU || canModifyStatus) && !isScanning) { - threeDotsMenuItems.push({ - icon: Expensicons.Stopwatch, - text: translate('iou.hold'), - onSelected: () => changeMoneyRequestStatus(), - }); - } - } - useEffect(() => { if (isLoadingHoldUseExplained) { return; @@ -199,14 +126,6 @@ function MoneyRequestHeader({report, parentReportAction, policy, shouldUseNarrow IOU.dismissHoldUseExplanation(); }; - if (canDeleteRequest) { - threeDotsMenuItems.push({ - icon: Expensicons.Trashcan, - text: translate('reportActionContextMenu.deleteAction', {action: parentReportAction}), - onSelected: () => setIsDeleteModalVisible(true), - }); - } - return ( <> @@ -215,9 +134,6 @@ function MoneyRequestHeader({report, parentReportAction, policy, shouldUseNarrow shouldShowReportAvatarWithDisplay shouldEnableDetailPageNavigation shouldShowPinButton={false} - shouldShowThreeDotsButton - threeDotsMenuItems={threeDotsMenuItems} - threeDotsAnchorPosition={styles.threeDotsPopoverOffsetNoCloseButton(windowWidth)} report={{ ...report, ownerAccountID: parentReport?.ownerAccountID, @@ -281,18 +197,6 @@ function MoneyRequestHeader({report, parentReportAction, policy, shouldUseNarrow )} - setIsDeleteModalVisible(false)} - onModalHide={() => ReportUtils.navigateBackAfterDeleteTransaction(navigateBackToAfterDelete.current)} - prompt={translate('iou.deleteConfirmation')} - confirmText={translate('common.delete')} - cancelText={translate('common.cancel')} - danger - shouldEnableNewFocusManagement - /> {isSmallScreenWidth && shouldShowHoldMenu && ( & {style: Array}; +type StrikethroughProps = Partial & {style: AllStyles[]}; function OfflineWithFeedback({ pendingAction, @@ -107,9 +107,10 @@ function OfflineWithFeedback({ return child; } - const childProps: {children: React.ReactNode | undefined; style: AllStyles} = child.props; + type ChildComponentProps = ChildrenProps & {style?: AllStyles}; + const childProps = child.props as ChildComponentProps; const props: StrikethroughProps = { - style: StyleUtils.combineStyles(childProps.style, styles.offlineFeedback.deleted, styles.userSelectNone), + style: StyleUtils.combineStyles(childProps.style ?? [], styles.offlineFeedback.deleted, styles.userSelectNone), }; if (childProps.children) { diff --git a/src/components/Pressable/PressableWithDelayToggle.tsx b/src/components/Pressable/PressableWithDelayToggle.tsx index 86f6c9d8aff8..617811637525 100644 --- a/src/components/Pressable/PressableWithDelayToggle.tsx +++ b/src/components/Pressable/PressableWithDelayToggle.tsx @@ -99,7 +99,7 @@ function PressableWithDelayToggle( return ( {ReportUtils.reportFieldsEnabled(report) && sortedPolicyReportFields.map((reportField) => { - const isTitleField = ReportUtils.isReportFieldOfTypeTitle(reportField); - const fieldValue = isTitleField ? report.reportName : reportField.value ?? reportField.defaultValue; + if (ReportUtils.isReportFieldOfTypeTitle(reportField)) { + return null; + } + + const fieldValue = reportField.value ?? reportField.defaultValue; const isFieldDisabled = ReportUtils.isReportFieldDisabled(report, reportField, policy); const fieldKey = ReportUtils.getReportFieldKey(reportField.fieldID); diff --git a/src/components/ReportActionItem/MoneyRequestPreview/MoneyRequestPreviewContent.tsx b/src/components/ReportActionItem/MoneyRequestPreview/MoneyRequestPreviewContent.tsx index 9e31dc110579..896432708aff 100644 --- a/src/components/ReportActionItem/MoneyRequestPreview/MoneyRequestPreviewContent.tsx +++ b/src/components/ReportActionItem/MoneyRequestPreview/MoneyRequestPreviewContent.tsx @@ -1,9 +1,11 @@ +import type {RouteProp} from '@react-navigation/native'; import {useRoute} from '@react-navigation/native'; import lodashSortBy from 'lodash/sortBy'; import truncate from 'lodash/truncate'; import React, {useMemo} from 'react'; import {View} from 'react-native'; import type {GestureResponderEvent} from 'react-native'; +import {useOnyx} from 'react-native-onyx'; import type {OnyxEntry} from 'react-native-onyx'; import Button from '@components/Button'; import Icon from '@components/Icon'; @@ -27,6 +29,8 @@ import ControlSelection from '@libs/ControlSelection'; import * as CurrencyUtils from '@libs/CurrencyUtils'; import * as DeviceCapabilities from '@libs/DeviceCapabilities'; import * as IOUUtils from '@libs/IOUUtils'; +import Navigation from '@libs/Navigation/Navigation'; +import type {TransactionDuplicateNavigatorParamList} from '@libs/Navigation/types'; import * as OptionsListUtils from '@libs/OptionsListUtils'; import * as ReceiptUtils from '@libs/ReceiptUtils'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; @@ -41,6 +45,7 @@ import * as Report from '@userActions/Report'; import * as Transaction from '@userActions/Transaction'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; import type {OriginalMessageIOU} from '@src/types/onyx/OriginalMessage'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; @@ -72,7 +77,7 @@ function MoneyRequestPreviewContent({ const StyleUtils = useStyleUtils(); const {translate} = useLocalize(); const {windowWidth} = useWindowDimensions(); - const route = useRoute(); + const route = useRoute>(); const {shouldUseNarrowLayout} = useResponsiveLayout(); const sessionAccountID = session?.accountID; @@ -126,6 +131,9 @@ function MoneyRequestPreviewContent({ const showCashOrCard = isCardTransaction ? translate('iou.card') : translate('iou.cash'); const shouldShowHoldMessage = !(isSettled && !isSettlementOrApprovalPartial) && isOnHold; + const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${route.params?.threadReportID}`); + const parentReportAction = ReportActionsUtils.getReportAction(report?.parentReportID ?? '', report?.parentReportActionID ?? ''); + const reviewingTransactionID = ReportActionsUtils.isMoneyRequestAction(parentReportAction) ? ReportActionsUtils.getOriginalMessage(parentReportAction)?.IOUTransactionID ?? '-1' : '-1'; // Get transaction violations for given transaction id from onyx, find duplicated transactions violations and get duplicates const duplicates = useMemo( () => @@ -264,6 +272,29 @@ function MoneyRequestPreviewContent({ [shouldShowSplitShare, isPolicyExpenseChat, action.actorAccountID, participantAccountIDs.length, transaction?.comment?.splits, requestAmount, requestCurrency, sessionAccountID], ); + const navigateToReviewFields = () => { + const comparisonResult = TransactionUtils.compareDuplicateTransactionFields(reviewingTransactionID); + const allTransactionIDsDuplicates = [reviewingTransactionID, ...duplicates].filter((id) => id !== transaction?.transactionID); + Transaction.setReviewDuplicatesKey({...comparisonResult.keep, duplicates: allTransactionIDsDuplicates, transactionID: transaction?.transactionID ?? ''}); + if ('merchant' in comparisonResult.change) { + Navigation.navigate(ROUTES.TRANSACTION_DUPLICATE_REVIEW_MERCHANT_PAGE.getRoute(route.params?.threadReportID)); + } else if ('category' in comparisonResult.change) { + Navigation.navigate(ROUTES.TRANSACTION_DUPLICATE_REVIEW_CATEGORY_PAGE.getRoute(route.params?.threadReportID)); + } else if ('tag' in comparisonResult.change) { + Navigation.navigate(ROUTES.TRANSACTION_DUPLICATE_REVIEW_TAG_PAGE.getRoute(route.params?.threadReportID)); + } else if ('description' in comparisonResult.change) { + Navigation.navigate(ROUTES.TRANSACTION_DUPLICATE_REVIEW_DESCRIPTION_PAGE.getRoute(route.params?.threadReportID)); + } else if ('taxCode' in comparisonResult.change) { + Navigation.navigate(ROUTES.TRANSACTION_DUPLICATE_REVIEW_TAX_CODE_PAGE.getRoute(route.params?.threadReportID)); + } else if ('billable' in comparisonResult.change) { + Navigation.navigate(ROUTES.TRANSACTION_DUPLICATE_REVIEW_BILLABLE_PAGE.getRoute(route.params?.threadReportID)); + } else if ('reimbursable' in comparisonResult.change) { + Navigation.navigate(ROUTES.TRANSACTION_DUPLICATE_REVIEW_REIMBURSABLE_PAGE.getRoute(route.params?.threadReportID)); + } else { + // Navigation to confirm screen will be done in seperate PR + } + }; + const childContainer = ( { - Transaction.setReviewDuplicatesKey(transaction?.transactionID ?? '', duplicates); - }} + onPress={navigateToReviewFields} /> )} diff --git a/src/components/ReportActionItem/ReportPreview.tsx b/src/components/ReportActionItem/ReportPreview.tsx index 9693b982ec4a..d986af8f5cf3 100644 --- a/src/components/ReportActionItem/ReportPreview.tsx +++ b/src/components/ReportActionItem/ReportPreview.tsx @@ -138,7 +138,6 @@ function ReportPreview({ const moneyRequestComment = action?.childLastMoneyRequestComment ?? ''; const isPolicyExpenseChat = ReportUtils.isPolicyExpenseChat(chatReport); - const isInvoiceRoom = ReportUtils.isInvoiceRoom(chatReport); const isOpenExpenseReport = isPolicyExpenseChat && ReportUtils.isOpenExpenseReport(iouReport); const isApproved = ReportUtils.isReportApproved(iouReport, action); @@ -178,7 +177,7 @@ function ReportPreview({ [chatReport?.isOwnPolicyExpenseChat, policy?.harvesting?.enabled], ); - const confirmPayment = (type: PaymentMethodType | undefined, payAsBusiness?: boolean) => { + const confirmPayment = (type: PaymentMethodType | undefined) => { if (!type) { return; } @@ -188,7 +187,7 @@ function ReportPreview({ setIsHoldMenuVisible(true); } else if (chatReport && iouReport) { if (ReportUtils.isInvoiceReport(iouReport)) { - IOU.payInvoice(type, chatReport, iouReport, payAsBusiness); + IOU.payInvoice(type, chatReport, iouReport); } else { IOU.payMoneyRequest(type, chatReport, iouReport); } @@ -247,16 +246,7 @@ function ReportPreview({ if (isScanning) { return translate('common.receipt'); } - - let payerOrApproverName; - if (isPolicyExpenseChat) { - payerOrApproverName = ReportUtils.getPolicyName(chatReport); - } else if (isInvoiceRoom) { - payerOrApproverName = ReportUtils.getInvoicePayerName(chatReport); - } else { - payerOrApproverName = ReportUtils.getDisplayNameForParticipant(managerID, true); - } - + let payerOrApproverName = isPolicyExpenseChat ? ReportUtils.getPolicyName(chatReport) : ReportUtils.getDisplayNameForParticipant(managerID, true); if (isApproved) { return translate('iou.managerApproved', {manager: payerOrApproverName}); } diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index 6414501fb06d..5454ffc61757 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -24,7 +24,6 @@ import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type SearchResults from '@src/types/onyx/SearchResults'; import type {SearchDataTypes, SearchQuery} from '@src/types/onyx/SearchResults'; -import {isEmptyObject} from '@src/types/utils/EmptyObject'; import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; import SearchListWithHeader from './SearchListWithHeader'; import SearchPageHeader from './SearchPageHeader'; @@ -90,7 +89,7 @@ function Search({query, policyIDs, sortBy, sortOrder}: SearchProps) { const isLoadingItems = (!isOffline && isLoadingOnyxValue(searchResultsMeta)) || searchResults?.data === undefined; const isLoadingMoreItems = !isLoadingItems && searchResults?.search?.isLoading && searchResults?.search?.offset > 0; - const shouldShowEmptyState = !isLoadingItems && isEmptyObject(searchResults?.data); + const shouldShowEmptyState = !isLoadingItems && SearchUtils.isSearchResultsEmpty(searchResults); if (isLoadingItems) { return ( diff --git a/src/components/SelectionList/BaseSelectionList.tsx b/src/components/SelectionList/BaseSelectionList.tsx index 503f7d11d2da..ae70e5525393 100644 --- a/src/components/SelectionList/BaseSelectionList.tsx +++ b/src/components/SelectionList/BaseSelectionList.tsx @@ -166,7 +166,7 @@ function BaseSelectionList( itemLayouts.push({length: fullItemHeight, offset}); offset += fullItemHeight; - if (item.isSelected && !selectedOptions.find((option) => option.text === item.text)) { + if (item.isSelected && !selectedOptions.find((option) => option.keyForList === item.keyForList)) { selectedOptions.push(item); } }); diff --git a/src/components/SettlementButton.tsx b/src/components/SettlementButton.tsx index 8375498ed4b7..7a7e4e584363 100644 --- a/src/components/SettlementButton.tsx +++ b/src/components/SettlementButton.tsx @@ -5,13 +5,11 @@ import {useOnyx, withOnyx} from 'react-native-onyx'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import Navigation from '@libs/Navigation/Navigation'; -import * as PolicyUtils from '@libs/PolicyUtils'; import * as ReportUtils from '@libs/ReportUtils'; import playSound, {SOUNDS} from '@libs/Sound'; import * as SubscriptionUtils from '@libs/SubscriptionUtils'; import * as BankAccounts from '@userActions/BankAccounts'; import * as IOU from '@userActions/IOU'; -import * as PolicyActions from '@userActions/Policy/Policy'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Route} from '@src/ROUTES'; @@ -43,7 +41,7 @@ type SettlementButtonOnyxProps = { type SettlementButtonProps = SettlementButtonOnyxProps & { /** Callback to execute when this button is pressed. Receives a single payment type argument. */ - onPress: (paymentType?: PaymentMethodType, payAsBusiness?: boolean) => void; + onPress: (paymentType?: PaymentMethodType) => void; /** The route to redirect if user does not have a payment method setup */ enablePaymentsRoute: EnablePaymentsRoute; @@ -145,9 +143,6 @@ function SettlementButton({ }: SettlementButtonProps) { const {translate} = useLocalize(); const {isOffline} = useNetwork(); - const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID); - - const primaryPolicy = useMemo(() => PolicyActions.getPrimaryPolicy(activePolicyID), [activePolicyID]); const session = useSession(); // The app would crash due to subscribing to the entire report collection if chatReportID is an empty string. So we should have a fallback ID here. @@ -204,39 +199,20 @@ function SettlementButton({ } if (isInvoiceReport) { - if (ReportUtils.isIndividualInvoiceRoom(chatReport)) { - buttonOptions.push({ - text: translate('iou.settlePersonal', {formattedAmount}), - icon: Expensicons.User, - value: CONST.IOU.PAYMENT_TYPE.ELSEWHERE, - backButtonText: translate('iou.individual'), - subMenuItems: [ - { - text: translate('iou.payElsewhere', {formattedAmount: ''}), - icon: Expensicons.Cash, - value: CONST.IOU.PAYMENT_TYPE.ELSEWHERE, - onSelected: () => onPress(CONST.IOU.PAYMENT_TYPE.ELSEWHERE), - }, - ], - }); - } - - if (PolicyUtils.isPolicyAdmin(primaryPolicy) && PolicyUtils.isPaidGroupPolicy(primaryPolicy)) { - buttonOptions.push({ - text: translate('iou.settleBusiness', {formattedAmount}), - icon: Expensicons.Building, - value: CONST.IOU.PAYMENT_TYPE.ELSEWHERE, - backButtonText: translate('iou.business'), - subMenuItems: [ - { - text: translate('iou.payElsewhere', {formattedAmount: ''}), - icon: Expensicons.Cash, - value: CONST.IOU.PAYMENT_TYPE.ELSEWHERE, - onSelected: () => onPress(CONST.IOU.PAYMENT_TYPE.ELSEWHERE, true), - }, - ], - }); - } + buttonOptions.push({ + text: translate('iou.settlePersonal', {formattedAmount}), + icon: Expensicons.User, + value: CONST.IOU.PAYMENT_TYPE.ELSEWHERE, + backButtonText: translate('iou.individual'), + subMenuItems: [ + { + text: translate('iou.payElsewhere', {formattedAmount: ''}), + icon: Expensicons.Cash, + value: CONST.IOU.PAYMENT_TYPE.ELSEWHERE, + onSelected: () => onPress(CONST.IOU.PAYMENT_TYPE.ELSEWHERE), + }, + ], + }); } if (shouldShowApproveButton) { @@ -250,7 +226,7 @@ function SettlementButton({ return buttonOptions; // We don't want to reorder the options when the preferred payment method changes while the button is still visible // eslint-disable-next-line react-hooks/exhaustive-deps - }, [currency, formattedAmount, iouReport, chatReport, policyID, translate, shouldHidePaymentOptions, primaryPolicy, shouldShowApproveButton, shouldDisableApproveButton]); + }, [currency, formattedAmount, iouReport, policyID, translate, shouldHidePaymentOptions, shouldShowApproveButton, shouldDisableApproveButton]); const selectPaymentType = (event: KYCFlowEvent, iouPaymentType: PaymentMethodType, triggerKYCFlow: TriggerKYCFlow) => { if (policy && SubscriptionUtils.shouldRestrictUserBillableActions(policy.id)) { @@ -283,7 +259,7 @@ function SettlementButton({ return ( onPress(paymentType)} + onSuccessfulKYC={onPress} enablePaymentsRoute={enablePaymentsRoute} addBankAccountRoute={addBankAccountRoute} addDebitCardRoute={addDebitCardRoute} diff --git a/src/components/StateSelector.tsx b/src/components/StateSelector.tsx index 2481c29d8123..4da8c33c2dc8 100644 --- a/src/components/StateSelector.tsx +++ b/src/components/StateSelector.tsx @@ -3,7 +3,7 @@ import {CONST as COMMON_CONST} from 'expensify-common'; import React, {useEffect, useRef} from 'react'; import type {ForwardedRef} from 'react'; import type {View} from 'react-native'; -import useGeographicalStateFromRoute from '@hooks/useGeographicalStateFromRoute'; +import useGeographicalStateAndCountryFromRoute from '@hooks/useGeographicalStateAndCountryFromRoute'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import Navigation from '@libs/Navigation/Navigation'; @@ -43,7 +43,7 @@ function StateSelector( ) { const styles = useThemeStyles(); const {translate} = useLocalize(); - const stateFromUrl = useGeographicalStateFromRoute(); + const {state: stateFromUrl} = useGeographicalStateAndCountryFromRoute(); const didOpenStateSelector = useRef(false); const isFocused = useIsFocused(); diff --git a/src/components/Tooltip/PopoverAnchorTooltip.tsx b/src/components/Tooltip/PopoverAnchorTooltip.tsx index 693de83fa5d7..5eb1f45dafcc 100644 --- a/src/components/Tooltip/PopoverAnchorTooltip.tsx +++ b/src/components/Tooltip/PopoverAnchorTooltip.tsx @@ -10,7 +10,7 @@ function PopoverAnchorTooltip({shouldRender = true, children, ...props}: Tooltip const isPopoverRelatedToTooltipOpen = useMemo(() => { // eslint-disable-next-line @typescript-eslint/dot-notation - const tooltipNode: Node | null = tooltipRef.current?.['_childNode'] ?? null; + const tooltipNode = (tooltipRef.current?.['_childNode'] as Node | undefined) ?? null; if ( isOpen && diff --git a/src/hooks/useGeographicalStateAndCountryFromRoute.ts b/src/hooks/useGeographicalStateAndCountryFromRoute.ts new file mode 100644 index 000000000000..b94644bdd287 --- /dev/null +++ b/src/hooks/useGeographicalStateAndCountryFromRoute.ts @@ -0,0 +1,27 @@ +import {useRoute} from '@react-navigation/native'; +import {CONST as COMMON_CONST} from 'expensify-common'; +import CONST from '@src/CONST'; + +type State = keyof typeof COMMON_CONST.STATES; +type Country = keyof typeof CONST.ALL_COUNTRIES; +type StateAndCountry = {state?: State; country?: Country}; + +/** + * Extracts the 'state' and 'country' query parameters from the route/ url and validates it against COMMON_CONST.STATES and CONST.ALL_COUNTRIES. + * Example 1: Url: https://new.expensify.com/settings/profile/address?state=MO Returns: state=MO + * Example 2: Url: https://new.expensify.com/settings/profile/address?state=ASDF Returns: state=undefined + * Example 3: Url: https://new.expensify.com/settings/profile/address Returns: state=undefined + * Example 4: Url: https://new.expensify.com/settings/profile/address?state=MO-hash-a12341 Returns: state=MO + * Similarly for country parameter. + */ +export default function useGeographicalStateAndCountryFromRoute(stateParamName = 'state', countryParamName = 'country'): StateAndCountry { + const routeParams = useRoute().params as Record; + + const stateFromUrlTemp = routeParams?.[stateParamName] as string | undefined; + const countryFromUrlTemp = routeParams?.[countryParamName] as string | undefined; + + return { + state: COMMON_CONST.STATES[stateFromUrlTemp as State]?.stateISO, + country: Object.keys(CONST.ALL_COUNTRIES).find((country) => country === countryFromUrlTemp) as Country, + }; +} diff --git a/src/hooks/useGeographicalStateFromRoute.ts b/src/hooks/useGeographicalStateFromRoute.ts deleted file mode 100644 index 434d4c534d61..000000000000 --- a/src/hooks/useGeographicalStateFromRoute.ts +++ /dev/null @@ -1,23 +0,0 @@ -import {useRoute} from '@react-navigation/native'; -import type {ParamListBase, RouteProp} from '@react-navigation/native'; -import {CONST as COMMON_CONST} from 'expensify-common'; - -type CustomParamList = ParamListBase & Record>; -type State = keyof typeof COMMON_CONST.STATES; - -/** - * Extracts the 'state' (default) query parameter from the route/ url and validates it against COMMON_CONST.STATES, returning its ISO code or `undefined`. - * Example 1: Url: https://new.expensify.com/settings/profile/address?state=MO Returns: MO - * Example 2: Url: https://new.expensify.com/settings/profile/address?state=ASDF Returns: undefined - * Example 3: Url: https://new.expensify.com/settings/profile/address Returns: undefined - * Example 4: Url: https://new.expensify.com/settings/profile/address?state=MO-hash-a12341 Returns: MO - */ -export default function useGeographicalStateFromRoute(stateParamName = 'state'): State | undefined { - const route = useRoute>(); - const stateFromUrlTemp = route.params?.[stateParamName] as string | undefined; - - if (!stateFromUrlTemp) { - return; - } - return COMMON_CONST.STATES[stateFromUrlTemp as State]?.stateISO; -} diff --git a/src/hooks/useReviewDuplicatesNavigation.tsx b/src/hooks/useReviewDuplicatesNavigation.tsx new file mode 100644 index 000000000000..f92abe63c852 --- /dev/null +++ b/src/hooks/useReviewDuplicatesNavigation.tsx @@ -0,0 +1,52 @@ +import {useEffect, useMemo, useState} from 'react'; +import Navigation from '@libs/Navigation/Navigation'; +import CONST from '@src/CONST'; +import ROUTES from '@src/ROUTES'; + +type StepName = 'description' | 'merchant' | 'category' | 'billable' | 'tag' | 'taxCode' | 'reimbursable'; + +function useReviewDuplicatesNavigation(stepNames: string[], currentScreenName: StepName, threadReportID: string) { + const [nextScreen, setNextScreen] = useState(currentScreenName); + const [currentScreenIndex, setCurrentScreenIndex] = useState(0); + const intersection = useMemo(() => CONST.REVIEW_DUPLICATES_ORDER.filter((element) => stepNames.includes(element)), [stepNames]); + + useEffect(() => { + const currentIndex = intersection.indexOf(currentScreenName); + const nextScreenIndex = currentIndex + 1; + setCurrentScreenIndex(currentIndex); + setNextScreen(intersection[nextScreenIndex] ?? ''); + }, [currentScreenName, intersection]); + + const navigateToNextScreen = () => { + switch (nextScreen) { + case 'merchant': + Navigation.navigate(ROUTES.TRANSACTION_DUPLICATE_REVIEW_MERCHANT_PAGE.getRoute(threadReportID)); + break; + case 'category': + Navigation.navigate(ROUTES.TRANSACTION_DUPLICATE_REVIEW_CATEGORY_PAGE.getRoute(threadReportID)); + break; + case 'tag': + Navigation.navigate(ROUTES.TRANSACTION_DUPLICATE_REVIEW_TAG_PAGE.getRoute(threadReportID)); + break; + case 'description': + Navigation.navigate(ROUTES.TRANSACTION_DUPLICATE_REVIEW_DESCRIPTION_PAGE.getRoute(threadReportID)); + break; + case 'taxCode': + Navigation.navigate(ROUTES.TRANSACTION_DUPLICATE_REVIEW_TAX_CODE_PAGE.getRoute(threadReportID)); + break; + case 'reimbursable': + Navigation.navigate(ROUTES.TRANSACTION_DUPLICATE_REVIEW_REIMBURSABLE_PAGE.getRoute(threadReportID)); + break; + case 'billable': + Navigation.navigate(ROUTES.TRANSACTION_DUPLICATE_REVIEW_BILLABLE_PAGE.getRoute(threadReportID)); + break; + default: + // Navigation to confirm screen will be done in seperate PR + break; + } + }; + + return {navigateToNextScreen, currentScreenIndex}; +} + +export default useReviewDuplicatesNavigation; diff --git a/src/hooks/useSubscriptionPossibleCostSavings.ts b/src/hooks/useSubscriptionPossibleCostSavings.ts index ef92009549fe..059445ce002d 100644 --- a/src/hooks/useSubscriptionPossibleCostSavings.ts +++ b/src/hooks/useSubscriptionPossibleCostSavings.ts @@ -13,10 +13,6 @@ const POSSIBLE_COST_SAVINGS = { [CONST.POLICY.TYPE.TEAM]: 1400, [CONST.POLICY.TYPE.CORPORATE]: 3000, }, - [CONST.PAYMENT_CARD_CURRENCY.GBP]: { - [CONST.POLICY.TYPE.TEAM]: 800, - [CONST.POLICY.TYPE.CORPORATE]: 1400, - }, [CONST.PAYMENT_CARD_CURRENCY.NZD]: { [CONST.POLICY.TYPE.TEAM]: 1600, [CONST.POLICY.TYPE.CORPORATE]: 3200, diff --git a/src/hooks/useSubscriptionPrice.ts b/src/hooks/useSubscriptionPrice.ts index 0b71fe62c7c8..9279ff94757d 100644 --- a/src/hooks/useSubscriptionPrice.ts +++ b/src/hooks/useSubscriptionPrice.ts @@ -25,16 +25,6 @@ const SUBSCRIPTION_PRICES = { [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, diff --git a/src/languages/en.ts b/src/languages/en.ts index 37b5c650e1b8..e3e080f26201 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -1,4 +1,5 @@ import {CONST as COMMON_CONST, Str} from 'expensify-common'; +import {startCase} from 'lodash'; import CONST from '@src/CONST'; import type {Country} from '@src/CONST'; import type {ConnectionName, PolicyConnectionSyncStage} from '@src/types/onyx/Policy'; @@ -353,6 +354,7 @@ export default { shared: 'Shared', drafts: 'Drafts', finished: 'Finished', + upgrade: 'Upgrade', companyID: 'Company ID', userID: 'User ID', disable: 'Disable', @@ -703,7 +705,6 @@ export default { settledExpensify: 'Paid', settledElsewhere: 'Paid elsewhere', individual: 'Individual', - business: 'Business', settleExpensify: ({formattedAmount}: SettleExpensifyCardParams) => (formattedAmount ? `Pay ${formattedAmount} with Expensify` : `Pay with Expensify`), settlePersonal: ({formattedAmount}: SettleExpensifyCardParams) => (formattedAmount ? `Pay ${formattedAmount} as an individual` : `Pay as an individual`), settlePayment: ({formattedAmount}: SettleExpensifyCardParams) => `Pay ${formattedAmount}`, @@ -2100,14 +2101,11 @@ export default { outOfPocketTaxEnabledError: 'Journal entries are unavailable when taxes are enabled. Please choose a different export option.', outOfPocketLocationEnabledError: 'Vendor bills are unavailable when locations are enabled. Please choose a different export option.', advancedConfig: { - advanced: 'Advanced', - autoSync: 'Auto-sync', autoSyncDescription: 'Expensify will automatically sync with QuickBooks Online every day.', inviteEmployees: 'Invite employees', inviteEmployeesDescription: 'Import Quickbooks Online employee records and invite employees to this workspace.', createEntities: 'Auto-create entities', createEntitiesDescription: "Expensify will automatically create vendors in QuickBooks Online if they don't exist already, and auto-create customers when exporting invoices.", - reimbursedReports: 'Sync reimbursed reports', reimbursedReportsDescription: 'Any time a report is paid using Expensify ACH, the corresponding bill payment will be created in the Quickbooks Online account below.', qboBillPaymentAccount: 'QuickBooks bill payment account', qboInvoiceCollectionAccount: 'QuickBooks invoice collections account', @@ -2172,11 +2170,8 @@ export default { salesInvoice: 'Sales invoice', exportInvoicesDescription: 'Sales invoices always display the date on which the invoice was sent.', advancedConfig: { - advanced: 'Advanced', - autoSync: 'Auto-sync', autoSyncDescription: 'Expensify will automatically sync with Xero every day.', purchaseBillStatusTitle: 'Purchase bill status', - reimbursedReports: 'Sync reimbursed reports', reimbursedReportsDescription: 'Any time a report is paid using Expensify ACH, the corresponding bill payment will be created in the Xero account below.', xeroBillPaymentAccount: 'Xero bill payment account', xeroInvoiceCollectionAccount: 'Xero invoice collections account', @@ -2295,6 +2290,49 @@ export default { }, }, }, + advancedConfig: { + autoSyncDescription: 'Expensify will automatically sync with NetSuite every day.', + reimbursedReportsDescription: 'Any time a report is paid using Expensify ACH, the corresponding bill payment will be created in the NetSuite account below.', + reimbursementsAccount: 'Reimbursements account', + collectionsAccount: 'Collections account', + approvalAccount: 'A/P approval account', + defaultApprovalAccount: 'NetSuite default', + inviteEmployees: 'Invite employees and set approvals', + inviteEmployeesDescription: + 'Import NetSuite employee records and invite employees to this workspace. Your approval workflow will default to manager approval and can be further configured on the *Members* page.', + autoCreateEntities: 'Auto-create employees/vendors', + enableCategories: 'Enable newly imported categories', + customFormID: 'Custom form ID', + customFormIDDescription: + 'By default, Expensify will create entries using the preferred transaction form set in NetSuite. Alternatively, you have the option to designate a specific transaction form to be used.', + customFormIDReimbursable: 'Reimbursable expense', + customFormIDNonReimbursable: 'Non-reimbursable expense', + exportReportsTo: { + label: 'Expense report approval level', + values: { + [CONST.NETSUITE_REPORTS_APPROVAL_LEVEL.REPORTS_APPROVED_NONE]: 'NetSuite default preference', + [CONST.NETSUITE_REPORTS_APPROVAL_LEVEL.REPORTS_SUPERVISOR_APPROVED]: 'Only supervisor approved', + [CONST.NETSUITE_REPORTS_APPROVAL_LEVEL.REPORTS_ACCOUNTING_APPROVED]: 'Only accounting approved', + [CONST.NETSUITE_REPORTS_APPROVAL_LEVEL.REPORTS_APPROVED_BOTH]: 'Supervisor and accounting approved', + }, + }, + exportVendorBillsTo: { + label: 'Vendor bill approval level', + values: { + [CONST.NETSUITE_VENDOR_BILLS_APPROVAL_LEVEL.VENDOR_BILLS_APPROVED_NONE]: 'NetSuite default preference', + [CONST.NETSUITE_VENDOR_BILLS_APPROVAL_LEVEL.VENDOR_BILLS_APPROVAL_PENDING]: 'Pending approval', + [CONST.NETSUITE_VENDOR_BILLS_APPROVAL_LEVEL.VENDOR_BILLS_APPROVED]: 'Approved for posting', + }, + }, + exportJournalsTo: { + label: 'Journal entry approval level', + values: { + [CONST.NETSUITE_JOURNALS_APPROVAL_LEVEL.JOURNALS_APPROVED_NONE]: 'NetSuite default preference', + [CONST.NETSUITE_JOURNALS_APPROVAL_LEVEL.JOURNALS_APPROVAL_PENDING]: 'Pending approval', + [CONST.NETSUITE_JOURNALS_APPROVAL_LEVEL.JOURNALS_APPROVED]: 'Approved for posting', + }, + }, + }, noAccountsFound: 'No accounts found', noAccountsFoundDescription: 'Add the account in NetSuite and sync the connection again.', noVendorsFound: 'No vendors found', @@ -2303,21 +2341,87 @@ export default { noItemsFoundDescription: 'Add invoice items in NetSuite and sync the connection again.', noSubsidiariesFound: 'No subsidiaries found', noSubsidiariesFoundDescription: 'Add the subsidiary in NetSuite and sync the connection again.', + tokenInput: { + title: 'NetSuite setup', + formSteps: { + installBundle: { + title: 'Install the Expensify bundle', + description: 'In NetSuite, go to *Customization > SuiteBundler > Search & Install Bundles* > search for "Expensify" > install the bundle.', + }, + enableTokenAuthentication: { + title: 'Enable token-based authentication', + description: 'In NetSuite, go to *Setup > Company > Enable Features > SuiteCloud* > enable *token-based authentication*.', + }, + enableSoapServices: { + title: 'Enable SOAP web services', + description: 'In NetSuite, go to *Setup > Company > Enable Features > SuiteCloud* > enable *SOAP Web Services*.', + }, + createAccessToken: { + title: 'Create an access token', + description: + 'In NetSuite, go to *Setup > Users/Roles > Access Tokens* > create an access token for the "Expensify" app and either the "Expensify Integration" or "Administrator" role.\n\n*Important:* Make sure you save the *Token ID* and *Token Secret* from this step. You\'ll need it for the next step.', + }, + enterCredentials: { + title: 'Enter your NetSuite credentials', + formInputs: { + netSuiteAccountID: 'NetSuite Account ID', + netSuiteTokenID: 'Token ID', + netSuiteTokenSecret: 'Token Secret', + }, + netSuiteAccountIDDescription: 'In NetSuite, go to *Setup > Integration > SOAP Web Services Preferences*.', + }, + }, + }, import: { expenseCategories: 'Expense categories', expenseCategoriesDescription: 'NetSuite expense categories import into Expensify as categories.', + crossSubsidiaryCustomers: 'Cross-subsidiary customer/projects', importFields: { - departments: 'Departments', - classes: 'Classes', - locations: 'Locations', - customers: 'Customers', - jobs: 'Projects (jobs)', + departments: { + title: 'Departments', + subtitle: 'Choose how to handle the NetSuite *departments* in Expensify.', + }, + classes: { + title: 'Classes', + subtitle: 'Choose how to handle *classes* in Expensify.', + }, + locations: { + title: 'Locations', + subtitle: 'Choose how to handle *locations* in Expensify.', + }, + }, + customersOrJobs: { + title: 'Customers / projects', + subtitle: 'Choose how to handle NetSuite *customers* and *projects* in Expensify.', + importCustomers: 'Import customers', + importJobs: 'Import projects', + customers: 'customers', + jobs: 'projects', + label: (importFields: string[], importType: string) => `${importFields.join(' and ')}, ${importType}`, }, importTaxDescription: 'Import tax groups from NetSuite', importCustomFields: { customSegments: 'Custom segments/records', customLists: 'Custom lists', }, + importTypes: { + [CONST.INTEGRATION_ENTITY_MAP_TYPES.NETSUITE_DEFAULT]: { + label: 'NetSuite employee default', + description: 'Not imported into Expensify, applied on export', + footerContent: (importField: string) => + `If you use ${importField} in NetSuite, we'll apply the default set on the employee record upon export to Expense Report or Journal Entry.`, + }, + [CONST.INTEGRATION_ENTITY_MAP_TYPES.TAG]: { + label: 'Tags', + description: 'Line-item level', + footerContent: (importField: string) => `${startCase(importField)} will be selectable for each individual expense on an employee's report.`, + }, + [CONST.INTEGRATION_ENTITY_MAP_TYPES.REPORT_FIELD]: { + label: 'Report fields', + description: 'Report level', + footerContent: (importField: string) => `${startCase(importField)} selection will apply to all expense on an employee's report.`, + }, + }, }, }, intacct: { @@ -2401,6 +2505,16 @@ export default { disableCardTitle: 'Disable Expensify Card', disableCardPrompt: 'You can’t disable the Expensify Card because it’s already in use. Reach out to Concierge for next steps.', disableCardButton: 'Chat with Concierge', + feed: { + title: 'Get the Expensify Card', + subTitle: 'Streamline your business with the Expensify Card', + features: { + cashBack: 'Up to 2% cash back on every US purchase', + unlimited: 'Issue unlimited virtual cards', + spend: 'Spend controls and custom limits', + }, + ctaTitle: 'Issue new card', + }, }, workflows: { title: 'Workflows', @@ -2806,6 +2920,8 @@ export default { exportPreferredExporterSubNote: 'Once set, the preferred exporter will see reports for export in their account.', exportAs: 'Export as', defaultVendor: 'Default vendor', + autoSync: 'Auto-sync', + reimbursedReports: 'Sync reimbursed reports', }, bills: { manageYourBills: 'Manage your bills', @@ -2968,6 +3084,30 @@ export default { errorDescriptionPartTwo: 'reach out to Concierge', errorDescriptionPartThree: 'for help.', }, + upgrade: { + reportFields: { + title: 'Report fields', + description: `Report fields let you specify header-level details, distinct from tags that pertain to expenses on individual line items. These details can encompass specific project names, business trip information, locations, and more.`, + pricing: { + onlyAvailableOnPlan: 'Report fields are only available on the Control plan, starting at ', + amount: '$9 ', + perActiveMember: 'per active member per month.', + }, + }, + note: { + upgradeWorkspace: 'Upgrade your workspace to access this feature, or', + learnMore: 'learn more', + aboutOurPlans: 'about our plans and pricing.', + }, + upgradeToUnlock: 'Unlock this feature', + completed: { + headline: `You've upgraded your workspace!`, + successMessage: (policyName: string) => `You've successfully upgraded your ${policyName} workspace to the Control plan!`, + viewSubscription: 'View your subscription', + moreDetails: 'for more details.', + gotIt: 'Got it, thanks', + }, + }, restrictedAction: { restricted: 'Restricted', actionsAreCurrentlyRestricted: ({workspaceName}) => `Actions on the ${workspaceName} workspace are currently restricted`, @@ -3505,6 +3645,14 @@ export default { taxOutOfPolicy: ({taxName}: ViolationsTaxOutOfPolicyParams) => `${taxName ?? 'Tax'} no longer valid`, taxRateChanged: 'Tax rate was modified', taxRequired: 'Missing tax rate', + none: 'None', + taxCodeToKeep: 'Choose which tax code to keep', + tagToKeep: 'Choose which tag to keep', + isTransactionReimbursable: 'Choose if transaction is reimbursable', + merchantToKeep: 'Choose which merchant to keep', + descriptionToKeep: 'Choose which description to keep', + categoryToKeep: 'Choose which category to keep', + isTransactionBillable: 'Choose if transaction is billable', keepThisOne: 'Keep this one', hold: 'Hold', }, diff --git a/src/languages/es.ts b/src/languages/es.ts index 754ca9037d3c..9e104ca9b1bb 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -344,6 +344,7 @@ export default { shared: 'Compartidos', drafts: 'Borradores', finished: 'Finalizados', + upgrade: 'Mejora', companyID: 'Empresa ID', userID: 'Usuario ID', disable: 'Deshabilitar', @@ -697,7 +698,6 @@ export default { settledExpensify: 'Pagado', settledElsewhere: 'Pagado de otra forma', individual: 'Individual', - business: 'Empresa', settleExpensify: ({formattedAmount}: SettleExpensifyCardParams) => (formattedAmount ? `Pagar ${formattedAmount} con Expensify` : `Pagar con Expensify`), settlePersonal: ({formattedAmount}: SettleExpensifyCardParams) => (formattedAmount ? `Pago ${formattedAmount} como individuo` : `Pago individual`), settlePayment: ({formattedAmount}: SettleExpensifyCardParams) => `Pagar ${formattedAmount}`, @@ -2134,14 +2134,11 @@ export default { 'QuickBooks Online no permite lugares en facturas de proveedores o cheques. Como tienes activadas los lugares en tu espacio de trabajo, estas opciones de exportación no están disponibles.', advancedConfig: { - advanced: 'Avanzado', - autoSync: 'Autosincronización', autoSyncDescription: 'Expensify se sincronizará automáticamente con QuickBooks Online todos los días.', inviteEmployees: 'Invitar empleados', inviteEmployeesDescription: 'Importe los registros de los empleados de Quickbooks Online e invítelos a este espacio de trabajo.', createEntities: 'Crear entidades automáticamente', createEntitiesDescription: 'Expensify creará automáticamente proveedores en QuickBooks Online si aún no existen, y creará automáticamente clientes al exportar facturas.', - reimbursedReports: 'Sincronizar informes reembolsados', reimbursedReportsDescription: 'Cada vez que se pague un informe utilizando Expensify ACH, se creará el correspondiente pago de la factura en la cuenta de Quickbooks Online indicadas a continuación.', qboBillPaymentAccount: 'Cuenta de pago de las facturas de QuickBooks', @@ -2212,11 +2209,8 @@ export default { salesInvoice: 'Factura de venta', exportInvoicesDescription: 'Las facturas de venta siempre muestran la fecha en la que se envió la factura.', advancedConfig: { - advanced: 'Avanzado', - autoSync: 'Autosincronización', autoSyncDescription: 'Expensify se sincronizará automáticamente con Xero todos los días.', purchaseBillStatusTitle: 'Estado de la factura de compra', - reimbursedReports: 'Sincronizar informes reembolsados', reimbursedReportsDescription: 'Cada vez que se pague un informe utilizando Expensify ACH, se creará el correspondiente pago de la factura en la cuenta de Xero indicadas a continuación.', xeroBillPaymentAccount: 'Cuenta de pago de las facturas de Xero', @@ -2336,6 +2330,50 @@ export default { }, }, }, + advancedConfig: { + autoSyncDescription: 'Expensify se sincronizará automáticamente con NetSuite todos los días.', + reimbursedReportsDescription: + 'Cada vez que se pague un informe utilizando Expensify ACH, se creará el correspondiente pago de la factura en la cuenta de NetSuite indicadas a continuación.', + reimbursementsAccount: 'Cuenta de reembolsos', + collectionsAccount: 'Cuenta de cobros', + approvalAccount: 'Cuenta de aprobación de cuentas por pagar', + defaultApprovalAccount: 'Preferencia predeterminada de NetSuite', + inviteEmployees: 'Invitar empleados y establecer aprobaciones', + inviteEmployeesDescription: + 'Importar registros de empleados de NetSuite e invitar a empleados a este espacio de trabajo. Su flujo de trabajo de aprobación será por defecto la aprobación del gerente y se puede configurar más en la página *Miembros*.', + autoCreateEntities: 'Crear automáticamente empleados/proveedores', + enableCategories: 'Activar categorías recién importadas', + customFormID: 'ID de formulario personalizado', + customFormIDDescription: + 'Por defecto, Expensify creará entradas utilizando el formulario de transacción preferido configurado en NetSuite. Alternativamente, tienes la opción de designar un formulario de transacción específico para ser utilizado.', + customFormIDReimbursable: 'Gasto reembolsable', + customFormIDNonReimbursable: 'Gasto no reembolsable', + exportReportsTo: { + label: 'Nivel de aprobación del informe de gastos', + values: { + [CONST.NETSUITE_REPORTS_APPROVAL_LEVEL.REPORTS_APPROVED_NONE]: 'Preferencia predeterminada de NetSuite', + [CONST.NETSUITE_REPORTS_APPROVAL_LEVEL.REPORTS_SUPERVISOR_APPROVED]: 'Solo aprobado por el supervisor', + [CONST.NETSUITE_REPORTS_APPROVAL_LEVEL.REPORTS_ACCOUNTING_APPROVED]: 'Solo aprobado por contabilidad', + [CONST.NETSUITE_REPORTS_APPROVAL_LEVEL.REPORTS_APPROVED_BOTH]: 'Aprobado por supervisor y contabilidad', + }, + }, + exportVendorBillsTo: { + label: 'Nivel de aprobación de facturas de proveedores', + values: { + [CONST.NETSUITE_VENDOR_BILLS_APPROVAL_LEVEL.VENDOR_BILLS_APPROVED_NONE]: 'Preferencia predeterminada de NetSuite', + [CONST.NETSUITE_VENDOR_BILLS_APPROVAL_LEVEL.VENDOR_BILLS_APPROVAL_PENDING]: 'Aprobación pendiente', + [CONST.NETSUITE_VENDOR_BILLS_APPROVAL_LEVEL.VENDOR_BILLS_APPROVED]: 'Aprobado para publicación', + }, + }, + exportJournalsTo: { + label: 'Nivel de aprobación de asientos contables', + values: { + [CONST.NETSUITE_JOURNALS_APPROVAL_LEVEL.JOURNALS_APPROVED_NONE]: 'Preferencia predeterminada de NetSuite', + [CONST.NETSUITE_JOURNALS_APPROVAL_LEVEL.JOURNALS_APPROVAL_PENDING]: 'Aprobación pendiente', + [CONST.NETSUITE_JOURNALS_APPROVAL_LEVEL.JOURNALS_APPROVED]: 'Aprobado para publicación', + }, + }, + }, noAccountsFound: 'No se han encontrado cuentas', noAccountsFoundDescription: 'Añade la cuenta en NetSuite y sincroniza la conexión de nuevo.', noVendorsFound: 'No se han encontrado proveedores', @@ -2344,21 +2382,87 @@ export default { noItemsFoundDescription: 'Añade artículos de factura en NetSuite y sincroniza la conexión de nuevo.', noSubsidiariesFound: 'No se ha encontrado subsidiarias', noSubsidiariesFoundDescription: 'Añade la subsidiaria en NetSuite y sincroniza de nuevo la conexión.', + tokenInput: { + title: 'Netsuite configuración', + formSteps: { + installBundle: { + title: 'Instala el paquete de Expensify', + description: 'En NetSuite, ir a *Personalización > SuiteBundler > Buscar e Instalar Paquetes* > busca "Expensify" > instala el paquete.', + }, + enableTokenAuthentication: { + title: 'Habilitar la autenticación basada en token', + description: 'En NetSuite, ir a *Configuración > Empresa > Habilitar Funciones > SuiteCloud* > activar *autenticación basada en token*.', + }, + enableSoapServices: { + title: 'Habilitar servicios web SOAP', + description: 'En NetSuite, ir a *Configuración > Empresa > Habilitar funciones > SuiteCloud* > habilitar *Servicios Web SOAP*.', + }, + createAccessToken: { + title: 'Crear un token de acceso', + description: + 'En NetSuite, ir a *Configuración > Usuarios/Roles > Tokens de Acceso* > crear un token de acceso para la aplicación "Expensify" y tambiém para el rol de "Integración Expensify" o "Administrador".\n\n*Importante:* Asegúrese de guardar el ID y el secreto del Token en este paso. Los necesitará para el siguiente paso.', + }, + enterCredentials: { + title: 'Ingresa tus credenciales de NetSuite', + formInputs: { + netSuiteAccountID: 'ID de Cuenta NetSuite', + netSuiteTokenID: 'ID de Token', + netSuiteTokenSecret: 'Secreto de Token', + }, + netSuiteAccountIDDescription: 'En NetSuite, ir a *Configuración > Integración > Preferencias de Servicios Web SOAP*.', + }, + }, + }, import: { expenseCategories: 'Categorías de gastos', expenseCategoriesDescription: 'Las categorías de gastos de NetSuite se importan a Expensify como categorías.', + crossSubsidiaryCustomers: 'Clientes/proyectos entre subsidiaria', importFields: { - departments: 'Departamentos', - classes: 'Clases', - locations: 'Ubicaciones', - customers: 'Clientes', - jobs: 'Proyectos (trabajos)', + departments: { + title: 'Departamentos', + subtitle: 'Elige cómo manejar los *departamentos* de NetSuite en Expensify.', + }, + classes: { + title: 'Clases', + subtitle: 'Elige cómo manejar las *clases* en Expensify.', + }, + locations: { + title: 'Ubicaciones', + subtitle: 'Elija cómo manejar *ubicaciones* en Expensify.', + }, + }, + customersOrJobs: { + title: 'Clientes / proyectos', + subtitle: 'Elija cómo manejar los *clientes* y *proyectos* de NetSuite en Expensify.', + importCustomers: 'Importar clientes', + importJobs: 'Importar proyectos', + customers: 'clientes', + jobs: 'proyectos', + label: (importFields: string[], importType: string) => `${importFields.join(' y ')}, ${importType}`, }, importTaxDescription: 'Importar grupos de impuestos desde NetSuite', importCustomFields: { - customSegments: 'Segmentos/registros personalizado', + customSegments: 'Segmentos/registros personalizados', customLists: 'Listas personalizado', }, + importTypes: { + [CONST.INTEGRATION_ENTITY_MAP_TYPES.NETSUITE_DEFAULT]: { + label: 'Predeterminado del empleado NetSuite', + description: 'No importado a Expensify, aplicado en exportación', + footerContent: (importField: string) => + `Si usa ${importField} en NetSuite, aplicaremos el conjunto predeterminado en el registro del empleado al exportarlo a Informe de gastos o Entrada de diario.`, + }, + [CONST.INTEGRATION_ENTITY_MAP_TYPES.TAG]: { + label: 'Etiquetas', + description: 'Nivel de línea de pedido', + footerContent: (importField: string) => `Se podrán seleccionar ${importField} para cada gasto individual en el informe de un empleado.`, + }, + [CONST.INTEGRATION_ENTITY_MAP_TYPES.REPORT_FIELD]: { + label: 'Campos de informe', + description: 'Nivel de informe', + footerContent: (importField: string) => `La selección de ${importField} se aplicará a todos los gastos en el informe de un empleado.`, + }, + }, }, }, intacct: { @@ -2439,6 +2543,16 @@ export default { disableCardTitle: 'Deshabilitar la Tarjeta Expensify', disableCardPrompt: 'No puedes deshabilitar la Tarjeta Expensify porque ya está en uso. Por favor, contacta con Concierge para conocer los pasos a seguir.', disableCardButton: 'Chatear con Concierge', + feed: { + title: 'Consigue la Tarjeta Expensify', + subTitle: 'Optimiza tu negocio con la Tarjeta Expensify', + features: { + cashBack: 'Hasta un 2% de devolución en cada compra en Estadios Unidos', + unlimited: 'Emitir un número ilimitado de tarjetas virtuales', + spend: 'Controles de gastos y límites personalizados', + }, + ctaTitle: 'Emitir nueva tarjeta', + }, }, distanceRates: { title: 'Tasas de distancia', @@ -2665,7 +2779,7 @@ export default { [CONST.INTEGRATION_ENTITY_MAP_TYPES.NOT_IMPORTED]: 'No importado', [CONST.INTEGRATION_ENTITY_MAP_TYPES.NONE]: 'No importado', [CONST.INTEGRATION_ENTITY_MAP_TYPES.REPORT_FIELD]: 'Importado como campos de informe', - [CONST.INTEGRATION_ENTITY_MAP_TYPES.NETSUITE_DEFAULT]: 'NetSuite employee default', + [CONST.INTEGRATION_ENTITY_MAP_TYPES.NETSUITE_DEFAULT]: 'Predeterminado del empleado NetSuite', }, disconnectPrompt: (currentIntegration?: ConnectionName): string => { const integrationName = @@ -2787,6 +2901,8 @@ export default { exportPreferredExporterSubNote: 'Una vez configurado, el exportador preferido verá los informes para exportar en tu cuenta.', exportAs: 'Exportar cómo', defaultVendor: 'Proveedor predeterminado', + autoSync: 'Autosincronización', + reimbursedReports: 'Sincronizar informes reembolsados', }, card: { header: 'Desbloquea Tarjetas Expensify gratis', @@ -3011,6 +3127,30 @@ export default { errorDescriptionPartTwo: 'contacta con el conserje', errorDescriptionPartThree: 'por ayuda.', }, + upgrade: { + reportFields: { + title: 'Los campos', + description: `Los campos de informe permiten especificar detalles a nivel de cabecera, distintos de las etiquetas que pertenecen a los gastos en partidas individuales. Estos detalles pueden incluir nombres de proyectos específicos, información sobre viajes de negocios, ubicaciones, etc.`, + pricing: { + onlyAvailableOnPlan: 'Los campos de informe sólo están disponibles en el plan Control, a partir de ', + amount: '$9 ', + perActiveMember: 'por miembro activo al mes.', + }, + }, + note: { + upgradeWorkspace: 'Mejore su espacio de trabajo para acceder a esta función, o', + learnMore: 'más información', + aboutOurPlans: 'sobre nuestros planes y precios.', + }, + upgradeToUnlock: 'Desbloquear esta función', + completed: { + headline: 'Has mejorado tu espacio de trabajo.', + successMessage: (policyName: string) => `Ha mejorado correctamente su espacio de trabajo ${policyName} al plan Control.`, + viewSubscription: 'Ver su suscripción', + moreDetails: 'para obtener más información.', + gotIt: 'Entendido, gracias.', + }, + }, restrictedAction: { restricted: 'Restringido', actionsAreCurrentlyRestricted: ({workspaceName}) => `Las acciones en el espacio de trabajo ${workspaceName} están actualmente restringidas`, @@ -4013,6 +4153,14 @@ export default { taxOutOfPolicy: ({taxName}: ViolationsTaxOutOfPolicyParams) => `${taxName ?? 'El impuesto'} ya no es válido`, taxRateChanged: 'La tasa de impuesto fue modificada', taxRequired: 'Falta la tasa de impuesto', + none: 'Ninguno', + taxCodeToKeep: 'Elige qué tasa de impuesto quieres conservar', + tagToKeep: 'Elige qué etiqueta quieres conservar', + isTransactionReimbursable: 'Elige si la transacción es reembolsable', + merchantToKeep: 'Elige qué comerciante quieres conservar', + descriptionToKeep: 'Elige qué descripción quieres conservar', + categoryToKeep: 'Elige qué categoría quieres conservar', + isTransactionBillable: 'Elige si la transacción es facturable', keepThisOne: 'Mantener éste', hold: 'Bloqueado', }, diff --git a/src/libs/API/index.ts b/src/libs/API/index.ts index ef9ba57767af..65fd2b6ad015 100644 --- a/src/libs/API/index.ts +++ b/src/libs/API/index.ts @@ -1,5 +1,6 @@ import type {OnyxUpdate} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; +import type {SetRequired} from 'type-fest'; import Log from '@libs/Log'; import * as Middleware from '@libs/Middleware'; import * as SequentialQueue from '@libs/Network/SequentialQueue'; @@ -7,11 +8,10 @@ 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 {PaginatedRequest, PaginationConfig} from '@src/types/onyx/Request'; import type Response from '@src/types/onyx/Response'; -import pkg from '../../../package.json'; -import type {ApiRequest, ApiRequestCommandParameters, ReadCommand, SideEffectRequestCommand, WriteCommand} from './types'; +import type {ApiCommand, ApiRequestCommandParameters, ApiRequestType, CommandOfType, ReadCommand, SideEffectRequestCommand, WriteCommand} from './types'; // Setup API middlewares. Each request made will pass through a series of middleware functions that will get called in sequence (each one passing the result of the previous to the next). // Note: The ordering here is intentional as we want to Log, Recheck Connection, Reauthenticate, and Save the Response in Onyx. Errors thrown in one middleware will bubble to the next. @@ -29,6 +29,8 @@ Request.use(Middleware.Reauthentication); // If an optimistic ID is not used by the server, this will update the remaining serialized requests using that optimistic ID to use the correct ID instead. Request.use(Middleware.HandleUnusedOptimisticID); +Request.use(Middleware.Pagination); + // SaveResponseInOnyx - Merges either the successData or failureData (or finallyData, if included in place of the former two values) into Onyx depending on if the call was successful or not. This needs to be the LAST middleware we use, don't add any // middlewares after this, because the SequentialQueue depends on the result of this middleware to pause the queue (if needed) to bring the app to an up-to-date state. Request.use(Middleware.SaveResponseInOnyx); @@ -40,70 +42,84 @@ 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 = -1; -Onyx.connect({ - key: ONYXKEYS.ONYX_UPDATES_LAST_UPDATE_ID_APPLIED_TO_CLIENT, - callback: (value) => { - if (value) { - lastUpdateIDAppliedToClient = value; - } else { - lastUpdateIDAppliedToClient = -1; - } - }, -}); - /** - * 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. - * - * @param command - Name of API command to call. - * @param apiCommandParameters - Parameters to send to the API. - * @param onyxData - Object containing errors, loading states, and optimistic UI data that will be merged - * into Onyx before and after a request is made. Each nested object will be formatted in - * the same way as an API response. - * @param [onyxData.optimisticData] - Onyx instructions that will be passed to Onyx.update() before the request is made. - * @param [onyxData.successData] - Onyx instructions that will be passed to Onyx.update() when the response has jsonCode === 200. - * @param [onyxData.failureData] - Onyx instructions that will be passed to Onyx.update() when the response has jsonCode !== 200. - * @param [onyxData.finallyData] - Onyx instructions that will be passed to Onyx.update() when the response has jsonCode === 200 or jsonCode !== 200. + * Prepare the request to be sent. Bind data together with request metadata and apply optimistic Onyx data. */ -function write(command: TCommand, apiCommandParameters: ApiRequestCommandParameters[TCommand], onyxData: OnyxData = {}) { - Log.info('Called API write', false, {command, ...apiCommandParameters}); - const {optimisticData, ...onyxDataWithoutOptimisticData} = onyxData; +function prepareRequest(command: TCommand, type: ApiRequestType, params: ApiRequestCommandParameters[TCommand], onyxData: OnyxData = {}): OnyxRequest { + Log.info('[API] Preparing request', false, {command, type}); - // Optimistically update Onyx + const {optimisticData, ...onyxDataWithoutOptimisticData} = onyxData; if (optimisticData) { + Log.info('[API] Applying optimistic data', false, {command, type}); Onyx.update(optimisticData); } - // Assemble the data we'll send to the API + const isWriteRequest = type === CONST.API_REQUEST_TYPE.WRITE; + + // Prepare the data we'll send to the API const data = { - ...apiCommandParameters, - appversion: pkg.version, - apiRequestType: CONST.API_REQUEST_TYPE.WRITE, + ...params, + apiRequestType: type, // We send the pusherSocketID with all write requests so that the api can include it in push events to prevent Pusher from sending the events to the requesting client. The push event // is sent back to the requesting client in the response data instead, which prevents a replay effect in the UI. See https://github.com/Expensify/App/issues/12775. - pusherSocketID: Pusher.getPusherSocketID(), + pusherSocketID: isWriteRequest ? Pusher.getPusherSocketID() : undefined, }; - // Assemble all the request data we'll be storing in the queue - const request: OnyxRequest = { + // Assemble all request metadata (used by middlewares, and for persisted requests stored in Onyx) + const request: SetRequired = { command, - data: { - ...data, - - // This should be removed once we are no longer using deprecatedAPI https://github.com/Expensify/Expensify/issues/215650 - shouldRetry: true, - canCancel: true, - clientUpdateID: lastUpdateIDAppliedToClient, - }, + data, ...onyxDataWithoutOptimisticData, }; + if (isWriteRequest) { + // This should be removed once we are no longer using deprecatedAPI https://github.com/Expensify/Expensify/issues/215650 + request.data.shouldRetry = true; + request.data.canCancel = true; + } + + return request; +} + +/** + * Process a prepared request according to its type. + */ +function processRequest(request: OnyxRequest, type: ApiRequestType): Promise { // Write commands can be saved and retried, so push it to the SequentialQueue - SequentialQueue.push(request); + if (type === CONST.API_REQUEST_TYPE.WRITE) { + SequentialQueue.push(request); + return Promise.resolve(); + } + + // Read requests are processed right away, but don't return the response to the caller + if (type === CONST.API_REQUEST_TYPE.READ) { + Request.processWithMiddleware(request); + return Promise.resolve(); + } + + // Requests with side effects process right away, and return the response to the caller + return Request.processWithMiddleware(request); +} + +/** + * 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. + * + * @param command - Name of API command to call. + * @param apiCommandParameters - Parameters to send to the API. + * @param onyxData - Object containing errors, loading states, and optimistic UI data that will be merged + * into Onyx before and after a request is made. Each nested object will be formatted in + * the same way as an API response. + * @param [onyxData.optimisticData] - Onyx instructions that will be passed to Onyx.update() before the request is made. + * @param [onyxData.successData] - Onyx instructions that will be passed to Onyx.update() when the response has jsonCode === 200. + * @param [onyxData.failureData] - Onyx instructions that will be passed to Onyx.update() when the response has jsonCode !== 200. + * @param [onyxData.finallyData] - Onyx instructions that will be passed to Onyx.update() when the response has jsonCode === 200 or jsonCode !== 200. + */ +function write(command: TCommand, apiCommandParameters: ApiRequestCommandParameters[TCommand], onyxData: OnyxData = {}): void { + Log.info('[API] Called API write', false, {command, ...apiCommandParameters}); + const request = prepareRequest(command, CONST.API_REQUEST_TYPE.WRITE, apiCommandParameters, onyxData); + processRequest(request, CONST.API_REQUEST_TYPE.WRITE); } /** @@ -123,41 +139,30 @@ function write(command: TCommand, apiCommandParam * @param [onyxData.successData] - Onyx instructions that will be passed to Onyx.update() when the response has jsonCode === 200. * @param [onyxData.failureData] - Onyx instructions that will be passed to Onyx.update() when the response has jsonCode !== 200. * @param [onyxData.finallyData] - Onyx instructions that will be passed to Onyx.update() when the response has jsonCode === 200 or jsonCode !== 200. - * @param [apiRequestType] - Can be either 'read', 'write', or 'makeRequestWithSideEffects'. We use this to either return the chained - * response back to the caller or to trigger reconnection callbacks when re-authentication is required. * @returns */ -function makeRequestWithSideEffects( +function makeRequestWithSideEffects( command: TCommand, apiCommandParameters: ApiRequestCommandParameters[TCommand], onyxData: OnyxData = {}, - apiRequestType: ApiRequest = CONST.API_REQUEST_TYPE.MAKE_REQUEST_WITH_SIDE_EFFECTS, ): Promise { - Log.info('Called API makeRequestWithSideEffects', false, {command, ...apiCommandParameters}); - const {optimisticData, ...onyxDataWithoutOptimisticData} = onyxData; - - // Optimistically update Onyx - if (optimisticData) { - Onyx.update(optimisticData); - } - - // Assemble the data we'll send to the API - const data = { - ...apiCommandParameters, - appversion: pkg.version, - apiRequestType, - clientUpdateID: lastUpdateIDAppliedToClient, - }; - - // Assemble all the request data we'll be storing - const request: OnyxRequest = { - command, - data, - ...onyxDataWithoutOptimisticData, - }; + Log.info('[API] Called API makeRequestWithSideEffects', false, {command, ...apiCommandParameters}); + const request = prepareRequest(command, CONST.API_REQUEST_TYPE.MAKE_REQUEST_WITH_SIDE_EFFECTS, apiCommandParameters, onyxData); // Return a promise containing the response from HTTPS - return Request.processWithMiddleware(request); + return processRequest(request, CONST.API_REQUEST_TYPE.MAKE_REQUEST_WITH_SIDE_EFFECTS); +} + +/** + * Ensure all write requests on the sequential queue have finished responding before running read requests. + * Responses from read requests can overwrite the optimistic data inserted by + * write requests that use the same Onyx keys and haven't responded yet. + */ +function waitForWrites(command: TCommand) { + if (PersistedRequests.getLength() > 0) { + Log.info(`[API] '${command}' is waiting on ${PersistedRequests.getLength()} write commands`); + } + return SequentialQueue.waitForIdle(); } /** @@ -173,14 +178,57 @@ function makeRequestWithSideEffects(command: TCommand, apiCommandParameters: ApiRequestCommandParameters[TCommand], onyxData: OnyxData = {}) { - // Ensure all write requests on the sequential queue have finished responding before running read requests. - // Responses from read requests can overwrite the optimistic data inserted by - // write requests that use the same Onyx keys and haven't responded yet. - if (PersistedRequests.getLength() > 0) { - Log.info(`[API] '${command}' is waiting on ${PersistedRequests.getLength()} write commands`); +function read(command: TCommand, apiCommandParameters: ApiRequestCommandParameters[TCommand], onyxData: OnyxData = {}): void { + Log.info('[API] Called API.read', false, {command, ...apiCommandParameters}); + + waitForWrites(command).then(() => { + const request = prepareRequest(command, CONST.API_REQUEST_TYPE.READ, apiCommandParameters, onyxData); + processRequest(request, CONST.API_REQUEST_TYPE.READ); + }); +} + +function paginate>( + type: TRequestType, + command: TCommand, + apiCommandParameters: ApiRequestCommandParameters[TCommand], + onyxData: OnyxData, + config: PaginationConfig, +): Promise; +function paginate>( + type: TRequestType, + command: TCommand, + apiCommandParameters: ApiRequestCommandParameters[TCommand], + onyxData: OnyxData, + config: PaginationConfig, +): void; +function paginate>( + type: TRequestType, + command: TCommand, + apiCommandParameters: ApiRequestCommandParameters[TCommand], + onyxData: OnyxData, + config: PaginationConfig, +): Promise | void { + Log.info('[API] Called API.paginate', false, {command, ...apiCommandParameters}); + const request: PaginatedRequest = { + ...prepareRequest(command, type, apiCommandParameters, onyxData), + ...config, + ...{ + isPaginated: true, + }, + }; + + switch (type) { + case CONST.API_REQUEST_TYPE.WRITE: + processRequest(request, type); + return; + case CONST.API_REQUEST_TYPE.MAKE_REQUEST_WITH_SIDE_EFFECTS: + return processRequest(request, type); + case CONST.API_REQUEST_TYPE.READ: + waitForWrites(command as ReadCommand).then(() => processRequest(request, type)); + return; + default: + throw new Error('Unknown API request type'); } - SequentialQueue.waitForIdle().then(() => makeRequestWithSideEffects(command, apiCommandParameters, onyxData, CONST.API_REQUEST_TYPE.READ)); } -export {write, makeRequestWithSideEffects, read}; +export {write, makeRequestWithSideEffects, read, paginate}; diff --git a/src/libs/API/parameters/ConnectPolicyToNetSuiteParams.ts b/src/libs/API/parameters/ConnectPolicyToNetSuiteParams.ts new file mode 100644 index 000000000000..2143ca1b039c --- /dev/null +++ b/src/libs/API/parameters/ConnectPolicyToNetSuiteParams.ts @@ -0,0 +1,8 @@ +type ConnectPolicyToNetSuiteParams = { + policyID: string; + netSuiteAccountID: string; + netSuiteTokenID: string; + netSuiteTokenSecret: string; +}; + +export default ConnectPolicyToNetSuiteParams; diff --git a/src/libs/API/parameters/OpenPolicyInitialPageParams.ts b/src/libs/API/parameters/OpenPolicyInitialPageParams.ts new file mode 100644 index 000000000000..764abe9a6a77 --- /dev/null +++ b/src/libs/API/parameters/OpenPolicyInitialPageParams.ts @@ -0,0 +1,5 @@ +type OpenPolicyInitialPageParams = { + policyID: string; +}; + +export default OpenPolicyInitialPageParams; diff --git a/src/libs/API/parameters/OpenPolicyProfilePageParams.ts b/src/libs/API/parameters/OpenPolicyProfilePageParams.ts new file mode 100644 index 000000000000..55dce33a3dac --- /dev/null +++ b/src/libs/API/parameters/OpenPolicyProfilePageParams.ts @@ -0,0 +1,5 @@ +type OpenPolicyProfilePageParams = { + policyID: string; +}; + +export default OpenPolicyProfilePageParams; diff --git a/src/libs/API/parameters/PayInvoiceParams.ts b/src/libs/API/parameters/PayInvoiceParams.ts index a6b9746d87bc..4c6633749adb 100644 --- a/src/libs/API/parameters/PayInvoiceParams.ts +++ b/src/libs/API/parameters/PayInvoiceParams.ts @@ -4,7 +4,6 @@ type PayInvoiceParams = { reportID: string; reportActionID: string; paymentMethodType: PaymentMethodType; - payAsBusiness: boolean; }; export default PayInvoiceParams; diff --git a/src/libs/API/parameters/PolicyReportFieldsReplace.ts b/src/libs/API/parameters/PolicyReportFieldsReplace.ts new file mode 100644 index 000000000000..c6d1834f0789 --- /dev/null +++ b/src/libs/API/parameters/PolicyReportFieldsReplace.ts @@ -0,0 +1,10 @@ +type PolicyReportFieldsReplace = { + policyID: string; + /** + * Stringified JSON object with type of following structure: + * Array + */ + reportFields: string; +}; + +export default PolicyReportFieldsReplace; diff --git a/src/libs/API/parameters/UpgradeToCorporateParams.ts b/src/libs/API/parameters/UpgradeToCorporateParams.ts new file mode 100644 index 000000000000..ee9d1359c4dd --- /dev/null +++ b/src/libs/API/parameters/UpgradeToCorporateParams.ts @@ -0,0 +1,6 @@ +type UpgradeToCorporateParams = { + policyID: string; + featureName: string; +}; + +export default UpgradeToCorporateParams; diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts index 0b63ec3ed465..a49cb68fd04f 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -12,6 +12,8 @@ export type {default as BeginSignInParams} from './BeginSignInParams'; export type {default as CloseAccountParams} from './CloseAccountParams'; export type {default as ConnectBankAccountParams} from './ConnectBankAccountParams'; export type {default as ConnectPolicyToAccountingIntegrationParams} from './ConnectPolicyToAccountingIntegrationParams'; +export type {default as OpenPolicyProfilePageParams} from './OpenPolicyProfilePageParams'; +export type {default as OpenPolicyInitialPageParams} from './OpenPolicyInitialPageParams'; export type {default as SyncPolicyToQuickbooksOnlineParams} from './SyncPolicyToQuickbooksOnlineParams'; export type {default as SyncPolicyToXeroParams} from './SyncPolicyToXeroParams'; export type {default as SyncPolicyToNetSuiteParams} from './SyncPolicyToNetSuiteParams'; @@ -234,10 +236,13 @@ export type {default as UpdateSubscriptionAutoRenewParams} from './UpdateSubscri export type {default as UpdateSubscriptionAddNewUsersAutomaticallyParams} from './UpdateSubscriptionAddNewUsersAutomaticallyParams'; export type {default as GenerateSpotnanaTokenParams} from './GenerateSpotnanaTokenParams'; export type {default as UpdateSubscriptionSizeParams} from './UpdateSubscriptionSizeParams'; +export type {default as UpgradeToCorporateParams} from './UpgradeToCorporateParams'; export type {default as DeleteMoneyRequestOnSearchParams} from './DeleteMoneyRequestOnSearchParams'; export type {default as HoldMoneyRequestOnSearchParams} from './HoldMoneyRequestOnSearchParams'; export type {default as UnholdMoneyRequestOnSearchParams} from './UnholdMoneyRequestOnSearchParams'; export type {default as UpdateNetSuiteSubsidiaryParams} from './UpdateNetSuiteSubsidiaryParams'; +export type {default as PolicyReportFieldsReplace} from './PolicyReportFieldsReplace'; +export type {default as ConnectPolicyToNetSuiteParams} from './ConnectPolicyToNetSuiteParams'; export type {default as CreateWorkspaceReportFieldParams} from './CreateWorkspaceReportFieldParams'; export type {default as OpenPolicyExpensifyCardsPageParams} from './OpenPolicyExpensifyCardsPageParams'; export type {default as RequestExpensifyCardLimitIncreaseParams} from './RequestExpensifyCardLimitIncreaseParams'; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index bd10be8948bc..dae65e7792bc 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -5,7 +5,7 @@ import type * as Parameters from './parameters'; import type SignInUserParams from './parameters/SignInUserParams'; import type UpdateBeneficialOwnersForBankAccountParams from './parameters/UpdateBeneficialOwnersForBankAccountParams'; -type ApiRequest = ValueOf; +type ApiRequestType = ValueOf; const WRITE_COMMANDS = { SET_WORKSPACE_AUTO_REPORTING_FREQUENCY: 'SetWorkspaceAutoReportingFrequency', @@ -131,6 +131,7 @@ const WRITE_COMMANDS = { RENAME_POLICY_TAG: 'RenamePolicyTag', SET_WORKSPACE_REQUIRES_CATEGORY: 'SetWorkspaceRequiresCategory', DELETE_WORKSPACE_CATEGORIES: 'DeleteWorkspaceCategories', + POLICY_REPORT_FIELDS_REPLACE: 'Policy_ReportFields_Replace', SET_POLICY_TAGS_REQUIRED: 'SetPolicyTagsRequired', SET_POLICY_REQUIRES_TAG: 'SetPolicyRequiresTag', RENAME_POLICY_TAG_LIST: 'RenamePolicyTaglist', @@ -229,6 +230,7 @@ const WRITE_COMMANDS = { UPDATE_SUBSCRIPTION_AUTO_RENEW: 'UpdateSubscriptionAutoRenew', UPDATE_SUBSCRIPTION_ADD_NEW_USERS_AUTOMATICALLY: 'UpdateSubscriptionAddNewUsersAutomatically', UPDATE_SUBSCRIPTION_SIZE: 'UpdateSubscriptionSize', + UPGRADE_TO_CORPORATE: 'UpgradeToCorporate', DELETE_MONEY_REQUEST_ON_SEARCH: 'DeleteMoneyRequestOnSearch', HOLD_MONEY_REQUEST_ON_SEARCH: 'HoldMoneyRequestOnSearch', UNHOLD_MONEY_REQUEST_ON_SEARCH: 'UnholdMoneyRequestOnSearch', @@ -236,6 +238,12 @@ const WRITE_COMMANDS = { UPDATE_NETSUITE_SUBSIDIARY: 'UpdateNetSuiteSubsidiary', CREATE_WORKSPACE_REPORT_FIELD: 'CreatePolicyReportField', UPDATE_NETSUITE_SYNC_TAX_CONFIGURATION: 'UpdateNetSuiteSyncTaxConfiguration', + UPDATE_NETSUITE_CROSS_SUBSIDIARY_CUSTOMER_CONFIGURATION: 'UpdateNetSuiteCrossSubsidiaryCustomerConfiguration', + UPDATE_NETSUITE_DEPARTMENTS_MAPPING: 'UpdateNetSuiteDepartmentsMapping', + UPDATE_NETSUITE_CLASSES_MAPPING: 'UpdateNetSuiteClassesMapping', + UPDATE_NETSUITE_LOCATIONS_MAPPING: 'UpdateNetSuiteLocationsMapping', + UPDATE_NETSUITE_CUSTOMERS_MAPPING: 'UpdateNetSuiteCustomersMapping', + UPDATE_NETSUITE_JOBS_MAPPING: 'UpdateNetSuiteJobsMapping', UPDATE_NETSUITE_EXPORTER: 'UpdateNetSuiteExporter', UPDATE_NETSUITE_EXPORT_DATE: 'UpdateNetSuiteExportDate', UPDATE_NETSUITE_REIMBURSABLE_EXPENSES_EXPORT_DESTINATION: 'UpdateNetSuiteReimbursableExpensesExportDestination', @@ -251,8 +259,15 @@ const WRITE_COMMANDS = { UPDATE_NETSUITE_TAX_POSTING_ACCOUNT: 'UpdateNetSuiteTaxPostingAccount', UPDATE_NETSUITE_ALLOW_FOREIGN_CURRENCY: 'UpdateNetSuiteAllowForeignCurrency', UPDATE_NETSUITE_EXPORT_TO_NEXT_OPEN_PERIOD: 'UpdateNetSuiteExportToNextOpenPeriod', + UPDATE_NETSUITE_AUTO_SYNC: 'UpdateNetSuiteAutoSync', + UPDATE_NETSUITE_SYNC_REIMBURSED_REPORTS: 'UpdateNetSuiteSyncReimbursedReports', + UPDATE_NETSUITE_SYNC_PEOPLE: 'UpdateNetSuiteSyncPeople', + UPDATE_NETSUITE_AUTO_CREATE_ENTITIES: 'UpdateNetSuiteAutoCreateEntities', + UPDATE_NETSUITE_ENABLE_NEW_CATEGORIES: 'UpdateNetSuiteEnableNewCategories', + UPDATE_NETSUITE_CUSTOM_FORM_ID_OPTIONS_ENABLED: 'UpdateNetSuiteCustomFormIDOptionsEnabled', REQUEST_EXPENSIFY_CARD_LIMIT_INCREASE: 'RequestExpensifyCardLimitIncrease', CONNECT_POLICY_TO_SAGE_INTACCT: 'ConnectPolicyToSageIntacct', + CONNECT_POLICY_TO_NETSUITE: 'ConnectPolicyToNetSuite', CLEAR_OUTSTANDING_BALANCE: 'ClearOutstandingBalance', } as const; @@ -374,6 +389,7 @@ type WriteCommandParameters = { [WRITE_COMMANDS.RENAME_WORKSPACE_CATEGORY]: Parameters.RenameWorkspaceCategoriesParams; [WRITE_COMMANDS.SET_WORKSPACE_REQUIRES_CATEGORY]: Parameters.SetWorkspaceRequiresCategoryParams; [WRITE_COMMANDS.DELETE_WORKSPACE_CATEGORIES]: Parameters.DeleteWorkspaceCategoriesParams; + [WRITE_COMMANDS.POLICY_REPORT_FIELDS_REPLACE]: Parameters.PolicyReportFieldsReplace; [WRITE_COMMANDS.SET_POLICY_REQUIRES_TAG]: Parameters.SetPolicyRequiresTag; [WRITE_COMMANDS.SET_POLICY_TAGS_REQUIRED]: Parameters.SetPolicyTagsRequired; [WRITE_COMMANDS.RENAME_POLICY_TAG_LIST]: Parameters.RenamePolicyTaglistParams; @@ -481,7 +497,6 @@ type WriteCommandParameters = { [WRITE_COMMANDS.MARK_AS_CASH]: Parameters.MarkAsCashParams; [WRITE_COMMANDS.UPDATE_SUBSCRIPTION_TYPE]: Parameters.UpdateSubscriptionTypeParams; [WRITE_COMMANDS.SIGN_UP_USER]: Parameters.SignUpUserParams; - [WRITE_COMMANDS.UPDATE_SUBSCRIPTION_AUTO_RENEW]: Parameters.UpdateSubscriptionAutoRenewParams; [WRITE_COMMANDS.UPDATE_SUBSCRIPTION_ADD_NEW_USERS_AUTOMATICALLY]: Parameters.UpdateSubscriptionAddNewUsersAutomaticallyParams; [WRITE_COMMANDS.UPDATE_SUBSCRIPTION_SIZE]: Parameters.UpdateSubscriptionSizeParams; @@ -493,13 +508,21 @@ type WriteCommandParameters = { [WRITE_COMMANDS.REQUEST_REFUND]: null; [WRITE_COMMANDS.CONNECT_POLICY_TO_SAGE_INTACCT]: Parameters.ConnectPolicyToSageIntacctParams; + [WRITE_COMMANDS.UPGRADE_TO_CORPORATE]: Parameters.UpgradeToCorporateParams; // Netsuite parameters [WRITE_COMMANDS.UPDATE_NETSUITE_SUBSIDIARY]: Parameters.UpdateNetSuiteSubsidiaryParams; + [WRITE_COMMANDS.CONNECT_POLICY_TO_NETSUITE]: Parameters.ConnectPolicyToNetSuiteParams; // Workspace report field parameters [WRITE_COMMANDS.CREATE_WORKSPACE_REPORT_FIELD]: Parameters.CreateWorkspaceReportFieldParams; [WRITE_COMMANDS.UPDATE_NETSUITE_SYNC_TAX_CONFIGURATION]: Parameters.UpdateNetSuiteGenericTypeParams<'enabled', boolean>; + [WRITE_COMMANDS.UPDATE_NETSUITE_CROSS_SUBSIDIARY_CUSTOMER_CONFIGURATION]: Parameters.UpdateNetSuiteGenericTypeParams<'enabled', boolean>; + [WRITE_COMMANDS.UPDATE_NETSUITE_DEPARTMENTS_MAPPING]: Parameters.UpdateNetSuiteGenericTypeParams<'mapping', ValueOf>; + [WRITE_COMMANDS.UPDATE_NETSUITE_CLASSES_MAPPING]: Parameters.UpdateNetSuiteGenericTypeParams<'mapping', ValueOf>; + [WRITE_COMMANDS.UPDATE_NETSUITE_LOCATIONS_MAPPING]: Parameters.UpdateNetSuiteGenericTypeParams<'mapping', ValueOf>; + [WRITE_COMMANDS.UPDATE_NETSUITE_CUSTOMERS_MAPPING]: Parameters.UpdateNetSuiteGenericTypeParams<'mapping', ValueOf>; + [WRITE_COMMANDS.UPDATE_NETSUITE_JOBS_MAPPING]: Parameters.UpdateNetSuiteGenericTypeParams<'mapping', ValueOf>; [WRITE_COMMANDS.UPDATE_NETSUITE_EXPORTER]: Parameters.UpdateNetSuiteGenericTypeParams<'email', string>; [WRITE_COMMANDS.UPDATE_NETSUITE_EXPORT_DATE]: Parameters.UpdateNetSuiteGenericTypeParams<'value', ValueOf>; [WRITE_COMMANDS.UPDATE_NETSUITE_REIMBURSABLE_EXPENSES_EXPORT_DESTINATION]: Parameters.UpdateNetSuiteGenericTypeParams<'value', ValueOf>; @@ -515,6 +538,12 @@ type WriteCommandParameters = { [WRITE_COMMANDS.UPDATE_NETSUITE_TAX_POSTING_ACCOUNT]: Parameters.UpdateNetSuiteGenericTypeParams<'bankAccountID', string>; [WRITE_COMMANDS.UPDATE_NETSUITE_ALLOW_FOREIGN_CURRENCY]: Parameters.UpdateNetSuiteGenericTypeParams<'enabled', boolean>; [WRITE_COMMANDS.UPDATE_NETSUITE_EXPORT_TO_NEXT_OPEN_PERIOD]: Parameters.UpdateNetSuiteGenericTypeParams<'enabled', boolean>; + [WRITE_COMMANDS.UPDATE_NETSUITE_AUTO_SYNC]: Parameters.UpdateNetSuiteGenericTypeParams<'enabled', boolean>; + [WRITE_COMMANDS.UPDATE_NETSUITE_SYNC_REIMBURSED_REPORTS]: Parameters.UpdateNetSuiteGenericTypeParams<'enabled', boolean>; + [WRITE_COMMANDS.UPDATE_NETSUITE_SYNC_PEOPLE]: Parameters.UpdateNetSuiteGenericTypeParams<'enabled', boolean>; + [WRITE_COMMANDS.UPDATE_NETSUITE_AUTO_CREATE_ENTITIES]: Parameters.UpdateNetSuiteGenericTypeParams<'enabled', boolean>; + [WRITE_COMMANDS.UPDATE_NETSUITE_ENABLE_NEW_CATEGORIES]: Parameters.UpdateNetSuiteGenericTypeParams<'enabled', boolean>; + [WRITE_COMMANDS.UPDATE_NETSUITE_CUSTOM_FORM_ID_OPTIONS_ENABLED]: Parameters.UpdateNetSuiteGenericTypeParams<'enabled', boolean>; }; const READ_COMMANDS = { @@ -561,6 +590,8 @@ const READ_COMMANDS = { OPEN_POLICY_DISTANCE_RATES_PAGE: 'OpenPolicyDistanceRatesPage', OPEN_POLICY_MORE_FEATURES_PAGE: 'OpenPolicyMoreFeaturesPage', OPEN_POLICY_ACCOUNTING_PAGE: 'OpenPolicyAccountingPage', + OPEN_POLICY_PROFILE_PAGE: 'OpenPolicyProfilePage', + OPEN_POLICY_INITIAL_PAGE: 'OpenPolicyInitialPage', SEARCH: 'Search', OPEN_SUBSCRIPTION_PAGE: 'OpenSubscriptionPage', } as const; @@ -611,6 +642,8 @@ type ReadCommandParameters = { [READ_COMMANDS.OPEN_POLICY_MORE_FEATURES_PAGE]: Parameters.OpenPolicyMoreFeaturesPageParams; [READ_COMMANDS.OPEN_POLICY_ACCOUNTING_PAGE]: Parameters.OpenPolicyAccountingPageParams; [READ_COMMANDS.OPEN_POLICY_EXPENSIFY_CARDS_PAGE]: Parameters.OpenPolicyExpensifyCardsPageParams; + [READ_COMMANDS.OPEN_POLICY_PROFILE_PAGE]: Parameters.OpenPolicyProfilePageParams; + [READ_COMMANDS.OPEN_POLICY_INITIAL_PAGE]: Parameters.OpenPolicyInitialPageParams; [READ_COMMANDS.SEARCH]: Parameters.SearchParams; [READ_COMMANDS.OPEN_SUBSCRIPTION_PAGE]: null; }; @@ -648,4 +681,11 @@ type ApiRequestCommandParameters = WriteCommandParameters & ReadCommandParameter export {WRITE_COMMANDS, READ_COMMANDS, SIDE_EFFECT_REQUEST_COMMANDS}; -export type {ApiRequest, ApiRequestCommandParameters, WriteCommand, ReadCommand, SideEffectRequestCommand}; +type ApiCommand = WriteCommand | ReadCommand | SideEffectRequestCommand; +type CommandOfType = TRequestType extends typeof CONST.API_REQUEST_TYPE.WRITE + ? WriteCommand + : TRequestType extends typeof CONST.API_REQUEST_TYPE.READ + ? ReadCommand + : SideEffectRequestCommand; + +export type {ApiCommand, ApiRequestType, ApiRequestCommandParameters, CommandOfType, WriteCommand, ReadCommand, SideEffectRequestCommand}; diff --git a/src/libs/Console/index.ts b/src/libs/Console/index.ts index f03d33674bde..9bbdb173e61b 100644 --- a/src/libs/Console/index.ts +++ b/src/libs/Console/index.ts @@ -87,8 +87,7 @@ const charMap: Record = { * @param text the text to sanitize * @returns the sanitized text */ -function sanitizeConsoleInput(text: string) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-return +function sanitizeConsoleInput(text: string): string { return text.replace(charsToSanitize, (match) => charMap[match]); } @@ -102,7 +101,7 @@ function createLog(text: string) { try { // @ts-expect-error Any code inside `sanitizedInput` that gets evaluated by `eval()` will be executed in the context of the current this value. // eslint-disable-next-line no-eval, no-invalid-this - const result = eval.call(this, text); + const result = eval.call(this, text) as unknown; if (result !== undefined) { return [ @@ -131,7 +130,7 @@ function parseStringifiedMessages(logs: Log[]): Log[] { return logs.map((log) => { try { - const parsedMessage = JSON.parse(log.message); + const parsedMessage = JSON.parse(log.message) as Log['message']; return { ...log, message: parsedMessage, diff --git a/src/libs/E2E/utils/NetworkInterceptor.ts b/src/libs/E2E/utils/NetworkInterceptor.ts index ad23afeb0c3b..511c8014f0cd 100644 --- a/src/libs/E2E/utils/NetworkInterceptor.ts +++ b/src/libs/E2E/utils/NetworkInterceptor.ts @@ -26,7 +26,7 @@ function getFetchRequestHeadersAsObject(fetchRequest: RequestInit): Record { - headers[key] = value; + headers[key] = value as string; }); } return headers; diff --git a/src/libs/ExportOnyxState/index.native.ts b/src/libs/ExportOnyxState/index.native.ts index 778b7f9f9cb2..bc32b29bc2ab 100644 --- a/src/libs/ExportOnyxState/index.native.ts +++ b/src/libs/ExportOnyxState/index.native.ts @@ -11,7 +11,7 @@ const readFromOnyxDatabase = () => db.executeAsync(query, []).then(({rows}) => { // eslint-disable-next-line no-underscore-dangle, @typescript-eslint/no-unsafe-member-access - const result = rows?._array.map((row) => ({[row?.record_key]: JSON.parse(row?.valueJSON as string)})); + const result = rows?._array.map((row) => ({[row?.record_key]: JSON.parse(row?.valueJSON as string) as unknown})); resolve(result); }); diff --git a/src/libs/Middleware/Pagination.ts b/src/libs/Middleware/Pagination.ts new file mode 100644 index 000000000000..ff5f5942674f --- /dev/null +++ b/src/libs/Middleware/Pagination.ts @@ -0,0 +1,137 @@ +import fastMerge from 'expensify-common/dist/fastMerge'; +import type {OnyxCollection} from 'react-native-onyx'; +import Onyx from 'react-native-onyx'; +import type {ApiCommand} from '@libs/API/types'; +import Log from '@libs/Log'; +import PaginationUtils from '@libs/PaginationUtils'; +import CONST from '@src/CONST'; +import type {OnyxCollectionKey, OnyxPagesKey, OnyxValues} from '@src/ONYXKEYS'; +import type {Request} from '@src/types/onyx'; +import type {PaginatedRequest} from '@src/types/onyx/Request'; +import type Middleware from './types'; + +type PagedResource = OnyxValues[TResourceKey] extends Record ? TResource : never; + +type PaginationCommonConfig = { + resourceCollectionKey: TResourceKey; + pageCollectionKey: TPageKey; + sortItems: (items: OnyxValues[TResourceKey]) => Array>; + getItemID: (item: PagedResource) => string; + isLastItem: (item: PagedResource) => boolean; +}; + +type PaginationConfig = PaginationCommonConfig & { + initialCommand: ApiCommand; + previousCommand: ApiCommand; + nextCommand: ApiCommand; +}; + +type PaginationConfigMapValue = PaginationCommonConfig & { + type: 'initial' | 'next' | 'previous'; +}; + +// Map of API commands to their pagination configs +const paginationConfigs = new Map(); + +// Local cache of paginated Onyx resources +const resources = new Map>(); + +// Local cache of Onyx pages objects +const pages = new Map>(); + +function registerPaginationConfig({ + initialCommand, + previousCommand, + nextCommand, + ...config +}: PaginationConfig): void { + paginationConfigs.set(initialCommand, {...config, type: 'initial'} as unknown as PaginationConfigMapValue); + paginationConfigs.set(previousCommand, {...config, type: 'previous'} as unknown as PaginationConfigMapValue); + paginationConfigs.set(nextCommand, {...config, type: 'next'} as unknown as PaginationConfigMapValue); + Onyx.connect({ + key: config.resourceCollectionKey, + waitForCollectionCallback: true, + callback: (data) => { + resources.set(config.resourceCollectionKey, data); + }, + }); + Onyx.connect({ + key: config.pageCollectionKey, + waitForCollectionCallback: true, + callback: (data) => { + pages.set(config.pageCollectionKey, data); + }, + }); +} + +function isPaginatedRequest(request: Request | PaginatedRequest): request is PaginatedRequest { + return 'isPaginated' in request && request.isPaginated; +} + +/** + * This middleware handles paginated requests marked with isPaginated: true. It works by: + * + * 1. Extracting the paginated resources from the response + * 2. Sorting them + * 3. Merging the new page of resources with any preexisting pages it overlaps with + * 4. Updating the saved pages in Onyx for that resource. + * + * It does this to keep track of what it's fetched via pagination and what may have showed up from other sources, + * so it can keep track of and fill any potential gaps in paginated lists. + */ +const Pagination: Middleware = (requestResponse, request) => { + const paginationConfig = paginationConfigs.get(request.command); + if (!paginationConfig || !isPaginatedRequest(request)) { + return requestResponse; + } + + const {resourceCollectionKey, pageCollectionKey, sortItems, getItemID, isLastItem, type} = paginationConfig; + const {resourceID, cursorID} = request; + return requestResponse.then((response) => { + if (!response?.onyxData) { + return Promise.resolve(response); + } + + const resourceKey = `${resourceCollectionKey}${resourceID}` as const; + const pageKey = `${pageCollectionKey}${resourceID}` as const; + + // Create a new page based on the response + const pageItems = (response.onyxData.find((data) => data.key === resourceKey)?.value ?? {}) as OnyxValues[typeof resourceCollectionKey]; + const sortedPageItems = sortItems(pageItems); + if (sortedPageItems.length === 0) { + // Must have at least 1 action to create a page. + Log.hmmm(`[Pagination] Did not receive any items in the response to ${request.command}`); + return Promise.resolve(response); + } + + const newPage = sortedPageItems.map((item) => getItemID(item)); + + // Detect if we are at the start of the list. This will always be the case for the initial request with no cursor. + // For previous requests we check that no new data is returned. Ideally the server would return that info. + if ((type === 'initial' && !cursorID) || (type === 'next' && newPage.length === 1 && newPage[0] === cursorID)) { + newPage.unshift(CONST.PAGINATION_START_ID); + } + if (isLastItem(sortedPageItems[sortedPageItems.length - 1])) { + newPage.push(CONST.PAGINATION_END_ID); + } + + const resourceCollections = resources.get(resourceCollectionKey) ?? {}; + const existingItems = resourceCollections[resourceKey] ?? {}; + const allItems = fastMerge(existingItems, pageItems, true); + const sortedAllItems = sortItems(allItems); + + const pagesCollections = pages.get(pageCollectionKey) ?? {}; + const existingPages = pagesCollections[pageKey] ?? []; + const mergedPages = PaginationUtils.mergeAndSortContinuousPages(sortedAllItems, [...existingPages, newPage], getItemID); + + response.onyxData.push({ + key: pageKey, + onyxMethod: Onyx.METHOD.SET, + value: mergedPages, + }); + + return Promise.resolve(response); + }); +}; + +export {Pagination, registerPaginationConfig}; diff --git a/src/libs/Middleware/index.ts b/src/libs/Middleware/index.ts index 3b1790b3cda5..7f02e23ad9b8 100644 --- a/src/libs/Middleware/index.ts +++ b/src/libs/Middleware/index.ts @@ -1,7 +1,8 @@ import HandleUnusedOptimisticID from './HandleUnusedOptimisticID'; import Logging from './Logging'; +import {Pagination} from './Pagination'; import Reauthentication from './Reauthentication'; import RecheckConnection from './RecheckConnection'; import SaveResponseInOnyx from './SaveResponseInOnyx'; -export {HandleUnusedOptimisticID, Logging, Reauthentication, RecheckConnection, SaveResponseInOnyx}; +export {HandleUnusedOptimisticID, Logging, Reauthentication, RecheckConnection, SaveResponseInOnyx, Pagination}; diff --git a/src/libs/Middleware/types.ts b/src/libs/Middleware/types.ts index 4cc0a1cc1026..794143123768 100644 --- a/src/libs/Middleware/types.ts +++ b/src/libs/Middleware/types.ts @@ -1,6 +1,7 @@ import type Request from '@src/types/onyx/Request'; +import type {PaginatedRequest} from '@src/types/onyx/Request'; import type Response from '@src/types/onyx/Response'; -type Middleware = (response: Promise, request: Request, isFromSequentialQueue: boolean) => Promise; +type Middleware = (response: Promise, request: Request | PaginatedRequest, isFromSequentialQueue: boolean) => Promise; export default Middleware; diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index 4fd6251ec644..0b001d747e5f 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -183,7 +183,7 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../../pages/settings/Profile/TimezoneSelectPage').default, [SCREENS.SETTINGS.PROFILE.LEGAL_NAME]: () => require('../../../../pages/settings/Profile/PersonalDetails/LegalNamePage').default, [SCREENS.SETTINGS.PROFILE.DATE_OF_BIRTH]: () => require('../../../../pages/settings/Profile/PersonalDetails/DateOfBirthPage').default, - [SCREENS.SETTINGS.PROFILE.ADDRESS]: () => require('../../../../pages/settings/Profile/PersonalDetails/AddressPage').default, + [SCREENS.SETTINGS.PROFILE.ADDRESS]: () => require('../../../../pages/settings/Profile/PersonalDetails/PersonalAddressPage').default, [SCREENS.SETTINGS.PROFILE.ADDRESS_COUNTRY]: () => require('../../../../pages/settings/Profile/PersonalDetails/CountrySelectionPage').default, [SCREENS.SETTINGS.PROFILE.ADDRESS_STATE]: () => require('../../../../pages/settings/Profile/PersonalDetails/StateSelectionPage').default, [SCREENS.SETTINGS.PROFILE.CONTACT_METHODS]: () => require('../../../../pages/settings/Profile/Contacts/ContactMethodsPage').default, @@ -197,7 +197,7 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../../pages/settings/AppDownloadLinks').default, [SCREENS.SETTINGS.CONSOLE]: () => require('../../../../pages/settings/AboutPage/ConsolePage').default, [SCREENS.SETTINGS.SHARE_LOG]: () => require('../../../../pages/settings/AboutPage/ShareLogPage').default, - [SCREENS.SETTINGS.WALLET.CARDS_DIGITAL_DETAILS_UPDATE_ADDRESS]: () => require('../../../../pages/settings/Profile/PersonalDetails/AddressPage').default, + [SCREENS.SETTINGS.WALLET.CARDS_DIGITAL_DETAILS_UPDATE_ADDRESS]: () => require('../../../../pages/settings/Profile/PersonalDetails/PersonalAddressPage').default, [SCREENS.SETTINGS.WALLET.DOMAIN_CARD]: () => require('../../../../pages/settings/Wallet/ExpensifyCardPage').default, [SCREENS.SETTINGS.WALLET.REPORT_VIRTUAL_CARD_FRAUD]: () => require('../../../../pages/settings/Wallet/ReportVirtualCardFraudPage').default, [SCREENS.SETTINGS.WALLET.CARD_ACTIVATE]: () => require('../../../../pages/settings/Wallet/ActivatePhysicalCardPage').default, @@ -230,6 +230,7 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../../pages/workspace/categories/CategorySettingsPage').default, [SCREENS.WORKSPACE.ADDRESS]: () => require('../../../../pages/workspace/WorkspaceProfileAddressPage').default, [SCREENS.WORKSPACE.CATEGORIES_SETTINGS]: () => require('../../../../pages/workspace/categories/WorkspaceCategoriesSettingsPage').default, + [SCREENS.WORKSPACE.UPGRADE]: () => require('../../../../pages/workspace/upgrade/WorkspaceUpgradePage').default, [SCREENS.WORKSPACE.MEMBER_DETAILS]: () => require('../../../../pages/workspace/members/WorkspaceMemberDetailsPage').default, [SCREENS.WORKSPACE.OWNER_CHANGE_CHECK]: () => require('@pages/workspace/members/WorkspaceOwnerChangeWrapperPage').default, [SCREENS.WORKSPACE.OWNER_CHANGE_SUCCESS]: () => require('../../../../pages/workspace/members/WorkspaceOwnerChangeSuccessPage').default, @@ -249,6 +250,7 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../../pages/workspace/tags/WorkspaceEditTagsPage').default, [SCREENS.WORKSPACE.TAG_CREATE]: () => require('../../../../pages/workspace/tags/WorkspaceCreateTagPage').default, [SCREENS.WORKSPACE.TAG_EDIT]: () => require('../../../../pages/workspace/tags/EditTagPage').default, + [SCREENS.WORKSPACE.REPORT_FIELD_SETTINGS]: () => require('../../../../pages/workspace/reportFields/WorkspaceReportFieldSettingsPage').default, [SCREENS.WORKSPACE.TAXES_SETTINGS]: () => require('../../../../pages/workspace/taxes/WorkspaceTaxesSettingsPage').default, [SCREENS.WORKSPACE.TAXES_SETTINGS_CUSTOM_TAX_NAME]: () => require('../../../../pages/workspace/taxes/WorkspaceTaxesSettingsCustomTaxName').default, [SCREENS.WORKSPACE.TAXES_SETTINGS_FOREIGN_CURRENCY_DEFAULT]: () => require('../../../../pages/workspace/taxes/WorkspaceTaxesSettingsForeignCurrency').default, @@ -320,7 +322,14 @@ const SettingsModalStackNavigator = createModalStackNavigator('../../../../pages/workspace/accounting/xero/advanced/XeroBillPaymentAccountSelectorPage').default, [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_SUBSIDIARY_SELECTOR]: () => require('../../../../pages/workspace/accounting/netsuite/NetSuiteSubsidiarySelector').default, + [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_TOKEN_INPUT]: () => + require('../../../../pages/workspace/accounting/netsuite/NetSuiteTokenInput/NetSuiteTokenInputPage').default, [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_IMPORT]: () => require('../../../../pages/workspace/accounting/netsuite/import/NetSuiteImportPage').default, + [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_IMPORT_MAPPING]: () => require('../../../../pages/workspace/accounting/netsuite/import/NetSuiteImportMappingPage').default, + [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_IMPORT_CUSTOMERS_OR_PROJECTS]: () => + require('../../../../pages/workspace/accounting/netsuite/import/NetSuiteImportCustomersOrProjectsPage').default, + [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_IMPORT_CUSTOMERS_OR_PROJECTS_SELECT]: () => + require('../../../../pages/workspace/accounting/netsuite/import/NetSuiteImportCustomersOrProjectSelectPage').default, [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_EXPORT]: () => require('../../../../pages/workspace/accounting/netsuite/export/NetSuiteExportConfigurationPage').default, [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_PREFERRED_EXPORTER_SELECT]: () => require('../../../../pages/workspace/accounting/netsuite/export/NetSuitePreferredExporterSelectPage').default, @@ -344,6 +353,7 @@ const SettingsModalStackNavigator = createModalStackNavigator('../../../../pages/workspace/accounting/netsuite/export/NetSuiteTaxPostingAccountSelectPage').default, [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_PROVINCIAL_TAX_POSTING_ACCOUNT_SELECT]: () => require('../../../../pages/workspace/accounting/netsuite/export/NetSuiteProvincialTaxPostingAccountSelectPage').default, + [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_ADVANCED]: () => require('../../../../pages/workspace/accounting/netsuite/advanced/NetSuiteAdvancedPage').default, [SCREENS.WORKSPACE.ACCOUNTING.SAGE_INTACCT_PREREQUISITES]: () => require('../../../../pages/workspace/accounting/intacct/IntacctPrerequisitesPage').default, [SCREENS.WORKSPACE.ACCOUNTING.ENTER_SAGE_INTACCT_CREDENTIALS]: () => @@ -410,6 +420,13 @@ const ProcessMoneyRequestHoldStackNavigator = createModalStackNavigator({ const TransactionDuplicateStackNavigator = createModalStackNavigator({ [SCREENS.TRANSACTION_DUPLICATE.REVIEW]: () => require('../../../../pages/TransactionDuplicate/Review').default, + [SCREENS.TRANSACTION_DUPLICATE.MERCHANT]: () => require('../../../../pages/TransactionDuplicate/ReviewMerchant').default, + [SCREENS.TRANSACTION_DUPLICATE.CATEGORY]: () => require('../../../../pages/TransactionDuplicate/ReviewCategory').default, + [SCREENS.TRANSACTION_DUPLICATE.TAG]: () => require('../../../../pages/TransactionDuplicate/ReviewTag').default, + [SCREENS.TRANSACTION_DUPLICATE.DESCRIPTION]: () => require('../../../../pages/TransactionDuplicate/ReviewDescription').default, + [SCREENS.TRANSACTION_DUPLICATE.TAX_CODE]: () => require('../../../../pages/TransactionDuplicate/ReviewTaxCode').default, + [SCREENS.TRANSACTION_DUPLICATE.BILLABLE]: () => require('../../../../pages/TransactionDuplicate/ReviewBillable').default, + [SCREENS.TRANSACTION_DUPLICATE.REIMBURSABLE]: () => require('../../../../pages/TransactionDuplicate/ReviewReimbursable').default, }); const SearchReportModalStackNavigator = createModalStackNavigator({ diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/useModalScreenOptions.ts b/src/libs/Navigation/AppNavigator/ModalStackNavigators/useModalScreenOptions.ts index 3c5ef1833835..460b0c732797 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/useModalScreenOptions.ts +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/useModalScreenOptions.ts @@ -1,19 +1,32 @@ -import type {StackNavigationOptions} from '@react-navigation/stack'; +import type {StackCardInterpolationProps, StackNavigationOptions} from '@react-navigation/stack'; import {CardStyleInterpolators} from '@react-navigation/stack'; import {useMemo} from 'react'; +import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; +import useWindowDimensions from '@hooks/useWindowDimensions'; +import {isSafari} from '@libs/Browser'; +import createModalCardStyleInterpolator from '@navigation/AppNavigator/createModalCardStyleInterpolator'; import type {ThemeStyles} from '@src/styles'; function useModalScreenOptions(getScreenOptions?: (styles: ThemeStyles) => StackNavigationOptions) { const styles = useThemeStyles(); + const styleUtils = useStyleUtils(); + const {isSmallScreenWidth} = useWindowDimensions(); + + let cardStyleInterpolator = CardStyleInterpolators.forHorizontalIOS; + + if (isSafari()) { + const customInterpolator = createModalCardStyleInterpolator(styleUtils); + cardStyleInterpolator = (props: StackCardInterpolationProps) => customInterpolator(isSmallScreenWidth, false, false, props); + } const defaultSubRouteOptions = useMemo( (): StackNavigationOptions => ({ cardStyle: styles.navigationScreenCardStyle, headerShown: false, - cardStyleInterpolator: CardStyleInterpolators.forHorizontalIOS, + cardStyleInterpolator, }), - [styles], + [styles, cardStyleInterpolator], ); return getScreenOptions?.(styles) ?? defaultSubRouteOptions; diff --git a/src/libs/Navigation/AppNavigator/Navigators/FullScreenNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/FullScreenNavigator.tsx index 16e8404f5fe9..748d92b49a1c 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/FullScreenNavigator.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/FullScreenNavigator.tsx @@ -19,7 +19,6 @@ type Screens = Partial React.Co const CENTRAL_PANE_WORKSPACE_SCREENS = { [SCREENS.WORKSPACE.PROFILE]: () => require('../../../../pages/workspace/WorkspaceProfilePage').default, [SCREENS.WORKSPACE.CARD]: () => require('../../../../pages/workspace/card/WorkspaceCardPage').default, - [SCREENS.WORKSPACE.EXPENSIFY_CARD]: () => require('../../../../pages/workspace/expensifyCard/WorkspaceExpensifyCardPage').default, [SCREENS.WORKSPACE.WORKFLOWS]: () => require('../../../../pages/workspace/workflows/WorkspaceWorkflowsPage').default, [SCREENS.WORKSPACE.REIMBURSE]: () => require('../../../../pages/workspace/reimburse/WorkspaceReimbursePage').default, [SCREENS.WORKSPACE.BILLS]: () => require('../../../../pages/workspace/bills/WorkspaceBillsPage').default, @@ -32,6 +31,7 @@ const CENTRAL_PANE_WORKSPACE_SCREENS = { [SCREENS.WORKSPACE.TAGS]: () => require('../../../../pages/workspace/tags/WorkspaceTagsPage').default, [SCREENS.WORKSPACE.TAXES]: () => require('../../../../pages/workspace/taxes/WorkspaceTaxesPage').default, [SCREENS.WORKSPACE.REPORT_FIELDS]: () => require('../../../../pages/workspace/reportFields/WorkspaceReportFieldsPage').default, + [SCREENS.WORKSPACE.EXPENSIFY_CARD]: () => require('../../../../pages/workspace/expensifyCard/WorkspaceExpensifyCardPage').default, [SCREENS.WORKSPACE.DISTANCE_RATES]: () => require('../../../../pages/workspace/distanceRates/PolicyDistanceRatesPage').default, } satisfies Screens; diff --git a/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx index 05c23797fe0e..6b83c1997693 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx @@ -1,12 +1,15 @@ -import type {StackScreenProps} from '@react-navigation/stack'; +import type {StackCardInterpolationProps, StackScreenProps} from '@react-navigation/stack'; import {createStackNavigator} from '@react-navigation/stack'; import React, {useMemo, useRef} from 'react'; import {View} from 'react-native'; import NoDropZone from '@components/DragAndDrop/NoDropZone'; +import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; +import {isSafari} from '@libs/Browser'; import ModalNavigatorScreenOptions from '@libs/Navigation/AppNavigator/ModalNavigatorScreenOptions'; import * as ModalStackNavigators from '@libs/Navigation/AppNavigator/ModalStackNavigators'; +import createModalCardStyleInterpolator from '@navigation/AppNavigator/createModalCardStyleInterpolator'; import type {AuthScreensParamList, RightModalNavigatorParamList} from '@navigation/types'; import NAVIGATORS from '@src/NAVIGATORS'; import SCREENS from '@src/SCREENS'; @@ -18,9 +21,19 @@ const Stack = createStackNavigator(); function RightModalNavigator({navigation}: RightModalNavigatorProps) { const styles = useThemeStyles(); + const styleUtils = useStyleUtils(); const {isSmallScreenWidth} = useWindowDimensions(); - const screenOptions = useMemo(() => ModalNavigatorScreenOptions(styles), [styles]); const isExecutingRef = useRef(false); + const screenOptions = useMemo(() => { + const options = ModalNavigatorScreenOptions(styles); + // The .forHorizontalIOS interpolator from `@react-navigation` is misbehaving on Safari, so we override it with Expensify custom interpolator + if (isSafari()) { + const customInterpolator = createModalCardStyleInterpolator(styleUtils); + options.cardStyleInterpolator = (props: StackCardInterpolationProps) => customInterpolator(isSmallScreenWidth, false, false, props); + } + + return options; + }, [isSmallScreenWidth, styleUtils, styles]); return ( diff --git a/src/libs/Navigation/linkTo/index.ts b/src/libs/Navigation/linkTo/index.ts index 2c23cf573248..d8312937ed6f 100644 --- a/src/libs/Navigation/linkTo/index.ts +++ b/src/libs/Navigation/linkTo/index.ts @@ -72,7 +72,8 @@ export default function linkTo(navigation: NavigationContainerRef> = { - [SCREENS.WORKSPACE.PROFILE]: [SCREENS.WORKSPACE.NAME, SCREENS.WORKSPACE.ADDRESS, SCREENS.WORKSPACE.CURRENCY, SCREENS.WORKSPACE.DESCRIPTION, SCREENS.WORKSPACE.SHARE], + [SCREENS.WORKSPACE.PROFILE]: [ + SCREENS.WORKSPACE.NAME, + SCREENS.WORKSPACE.ADDRESS, + SCREENS.WORKSPACE.CURRENCY, + SCREENS.WORKSPACE.DESCRIPTION, + SCREENS.WORKSPACE.SHARE, + SCREENS.WORKSPACE.UPGRADE, + ], [SCREENS.WORKSPACE.REIMBURSE]: [SCREENS.WORKSPACE.RATE_AND_UNIT, SCREENS.WORKSPACE.RATE_AND_UNIT_RATE, SCREENS.WORKSPACE.RATE_AND_UNIT_UNIT], [SCREENS.WORKSPACE.MEMBERS]: [ SCREENS.WORKSPACE.INVITE, @@ -56,7 +63,11 @@ const FULL_SCREEN_TO_RHP_MAPPING: Partial> = { SCREENS.WORKSPACE.ACCOUNTING.XERO_BILL_PAYMENT_ACCOUNT_SELECTOR, SCREENS.WORKSPACE.ACCOUNTING.XERO_EXPORT_BANK_ACCOUNT_SELECT, SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_SUBSIDIARY_SELECTOR, + SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_TOKEN_INPUT, SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_IMPORT, + SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_IMPORT_MAPPING, + SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_IMPORT_CUSTOMERS_OR_PROJECTS, + SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_IMPORT_CUSTOMERS_OR_PROJECTS_SELECT, SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_EXPORT, SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_PREFERRED_EXPORTER_SELECT, SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_DATE_SELECT, @@ -70,6 +81,7 @@ const FULL_SCREEN_TO_RHP_MAPPING: Partial> = { SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_INVOICE_ITEM_SELECT, SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_TAX_POSTING_ACCOUNT_SELECT, SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_PROVINCIAL_TAX_POSTING_ACCOUNT_SELECT, + SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_ADVANCED, SCREENS.WORKSPACE.ACCOUNTING.SAGE_INTACCT_PREREQUISITES, SCREENS.WORKSPACE.ACCOUNTING.ENTER_SAGE_INTACCT_CREDENTIALS, SCREENS.WORKSPACE.ACCOUNTING.EXISTING_SAGE_INTACCT_CONNECTIONS, @@ -103,6 +115,7 @@ const FULL_SCREEN_TO_RHP_MAPPING: Partial> = { SCREENS.WORKSPACE.DISTANCE_RATE_DETAILS, ], [SCREENS.WORKSPACE.REPORT_FIELDS]: [ + SCREENS.WORKSPACE.REPORT_FIELD_SETTINGS, SCREENS.WORKSPACE.REPORT_FIELDS_CREATE, SCREENS.WORKSPACE.REPORT_FIELDS_LIST_VALUES, SCREENS.WORKSPACE.REPORT_FIELDS_ADD_VALUE, diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts index 3f4896e0c5d2..6f24aaf82048 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -354,7 +354,11 @@ const config: LinkingOptions['config'] = { [SCREENS.WORKSPACE.ACCOUNTING.XERO_EXPORT_PREFERRED_EXPORTER_SELECT]: {path: ROUTES.POLICY_ACCOUNTING_XERO_PREFERRED_EXPORTER_SELECT.route}, [SCREENS.WORKSPACE.ACCOUNTING.XERO_BILL_PAYMENT_ACCOUNT_SELECTOR]: {path: ROUTES.POLICY_ACCOUNTING_XERO_BILL_PAYMENT_ACCOUNT_SELECTOR.route}, [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_SUBSIDIARY_SELECTOR]: {path: ROUTES.POLICY_ACCOUNTING_NETSUITE_SUBSIDIARY_SELECTOR.route}, + [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_TOKEN_INPUT]: {path: ROUTES.POLICY_ACCOUNTING_NETSUITE_TOKEN_INPUT.route}, [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_IMPORT]: {path: ROUTES.POLICY_ACCOUNTING_NETSUITE_IMPORT.route}, + [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_IMPORT_MAPPING]: {path: ROUTES.POLICY_ACCOUNTING_NETSUITE_IMPORT_MAPPING.route}, + [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_IMPORT_CUSTOMERS_OR_PROJECTS]: {path: ROUTES.POLICY_ACCOUNTING_NETSUITE_IMPORT_CUSTOMERS_OR_PROJECTS.route}, + [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_IMPORT_CUSTOMERS_OR_PROJECTS_SELECT]: {path: ROUTES.POLICY_ACCOUNTING_NETSUITE_IMPORT_CUSTOMERS_OR_PROJECTS_SELECT.route}, [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_EXPORT]: { path: ROUTES.POLICY_ACCOUNTING_NETSUITE_EXPORT.route, }, @@ -394,6 +398,9 @@ const config: LinkingOptions['config'] = { [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_PROVINCIAL_TAX_POSTING_ACCOUNT_SELECT]: { path: ROUTES.POLICY_ACCOUNTING_NETSUITE_PROVINCIAL_TAX_POSTING_ACCOUNT_SELECT.route, }, + [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_ADVANCED]: { + path: ROUTES.POLICY_ACCOUNTING_NETSUITE_ADVANCED.route, + }, [SCREENS.WORKSPACE.ACCOUNTING.SAGE_INTACCT_PREREQUISITES]: {path: ROUTES.POLICY_ACCOUNTING_SAGE_INTACCT_PREREQUISITES.route}, [SCREENS.WORKSPACE.ACCOUNTING.ENTER_SAGE_INTACCT_CREDENTIALS]: {path: ROUTES.POLICY_ACCOUNTING_SAGE_INTACCT_ENTER_CREDENTIALS.route}, [SCREENS.WORKSPACE.ACCOUNTING.EXISTING_SAGE_INTACCT_CONNECTIONS]: {path: ROUTES.POLICY_ACCOUNTING_SAGE_INTACCT_EXISTING_CONNECTIONS.route}, @@ -436,6 +443,12 @@ const config: LinkingOptions['config'] = { categoryName: (categoryName: string) => decodeURIComponent(categoryName), }, }, + [SCREENS.WORKSPACE.UPGRADE]: { + path: ROUTES.WORKSPACE_UPGRADE.route, + parse: { + featureName: (featureName: string) => decodeURIComponent(featureName), + }, + }, [SCREENS.WORKSPACE.CATEGORIES_SETTINGS]: { path: ROUTES.WORKSPACE_CATEGORIES_SETTINGS.route, }, @@ -513,6 +526,12 @@ const config: LinkingOptions['config'] = { orderWeight: Number, }, }, + [SCREENS.WORKSPACE.REPORT_FIELD_SETTINGS]: { + path: ROUTES.WORKSPACE_REPORT_FIELD_SETTINGS.route, + parse: { + reportFieldName: (reportFieldKey: string) => decodeURIComponent(reportFieldKey), + }, + }, [SCREENS.WORKSPACE.TAXES_SETTINGS]: { path: ROUTES.WORKSPACE_TAXES_SETTINGS.route, }, @@ -762,6 +781,34 @@ const config: LinkingOptions['config'] = { path: ROUTES.TRANSACTION_DUPLICATE_REVIEW_PAGE.route, exact: true, }, + [SCREENS.TRANSACTION_DUPLICATE.MERCHANT]: { + path: ROUTES.TRANSACTION_DUPLICATE_REVIEW_MERCHANT_PAGE.route, + exact: true, + }, + [SCREENS.TRANSACTION_DUPLICATE.CATEGORY]: { + path: ROUTES.TRANSACTION_DUPLICATE_REVIEW_CATEGORY_PAGE.route, + exact: true, + }, + [SCREENS.TRANSACTION_DUPLICATE.TAG]: { + path: ROUTES.TRANSACTION_DUPLICATE_REVIEW_TAG_PAGE.route, + exact: true, + }, + [SCREENS.TRANSACTION_DUPLICATE.DESCRIPTION]: { + path: ROUTES.TRANSACTION_DUPLICATE_REVIEW_DESCRIPTION_PAGE.route, + exact: true, + }, + [SCREENS.TRANSACTION_DUPLICATE.TAX_CODE]: { + path: ROUTES.TRANSACTION_DUPLICATE_REVIEW_TAX_CODE_PAGE.route, + exact: true, + }, + [SCREENS.TRANSACTION_DUPLICATE.REIMBURSABLE]: { + path: ROUTES.TRANSACTION_DUPLICATE_REVIEW_REIMBURSABLE_PAGE.route, + exact: true, + }, + [SCREENS.TRANSACTION_DUPLICATE.BILLABLE]: { + path: ROUTES.TRANSACTION_DUPLICATE_REVIEW_BILLABLE_PAGE.route, + exact: true, + }, }, }, [SCREENS.RIGHT_MODAL.SPLIT_DETAILS]: { diff --git a/src/libs/Navigation/linkingConfig/index.ts b/src/libs/Navigation/linkingConfig/index.ts index 64a40a224495..1f556aa67809 100644 --- a/src/libs/Navigation/linkingConfig/index.ts +++ b/src/libs/Navigation/linkingConfig/index.ts @@ -12,7 +12,6 @@ const linkingConfig: LinkingOptions = { const {adaptedState} = getAdaptedStateFromPath(...args); // ResultState | undefined is the type this function expect. - // eslint-disable-next-line @typescript-eslint/no-unsafe-return return adaptedState; }, subscribe, diff --git a/src/libs/Navigation/linkingConfig/subscribe/index.native.ts b/src/libs/Navigation/linkingConfig/subscribe/index.native.ts index 061bca092b7d..46720e9884e9 100644 --- a/src/libs/Navigation/linkingConfig/subscribe/index.native.ts +++ b/src/libs/Navigation/linkingConfig/subscribe/index.native.ts @@ -12,7 +12,7 @@ import SCREENS from '@src/SCREENS'; // This field in linkingConfig is supported on native only. const subscribe: LinkingOptions['subscribe'] = (listener) => { - // We need to ovverride the default behaviour for the deep link to search screen. + // We need to override the default behaviour for the deep link to search screen. // Even on mobile narrow layout, this screen need to push two screens on the stack to work (bottom tab and central pane). // That's why we are going to handle it with our navigate function instead the default react-navigation one. const linkingSubscription = Linking.addEventListener('url', ({url}) => { diff --git a/src/libs/Navigation/switchPolicyID.ts b/src/libs/Navigation/switchPolicyID.ts index 0f6477a9ee0e..19626a400b9d 100644 --- a/src/libs/Navigation/switchPolicyID.ts +++ b/src/libs/Navigation/switchPolicyID.ts @@ -32,11 +32,11 @@ function getActionForBottomTabNavigator(action: StackNavigationAction, state: Na return; } - let name; + let name: string | undefined; let params: Record; if (isCentralPaneName(action.payload.name)) { name = action.payload.name; - params = action.payload.params; + params = action.payload.params as Record; } else { const actionPayloadParams = action.payload.params as ActionPayloadParams; name = actionPayloadParams.screen; diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index ac9710b65d19..feb822e1e97c 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -10,7 +10,7 @@ import type { PartialState, Route, } from '@react-navigation/native'; -import type {ValueOf} from 'type-fest'; +import type {TupleToUnion, ValueOf} from 'type-fest'; import type {IOURequestType} from '@libs/actions/IOU'; import type {SearchColumnType, SortOrder} from '@libs/SearchUtils'; import type CONST from '@src/CONST'; @@ -205,6 +205,11 @@ type SettingsNavigatorParamList = { categoryName: string; backTo?: Routes; }; + [SCREENS.WORKSPACE.UPGRADE]: { + policyID: string; + featureName: string; + backTo?: Routes; + }; [SCREENS.WORKSPACE.CATEGORIES_SETTINGS]: { policyID: string; backTo?: Routes; @@ -236,6 +241,10 @@ type SettingsNavigatorParamList = { orderWeight: number; tagName: string; }; + [SCREENS.WORKSPACE.REPORT_FIELD_SETTINGS]: { + policyID: string; + reportFieldKey: string; + }; [SCREENS.WORKSPACE.TAG_LIST_VIEW]: { policyID: string; orderWeight: number; @@ -416,9 +425,22 @@ type SettingsNavigatorParamList = { [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_SUBSIDIARY_SELECTOR]: { policyID: string; }; + [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_TOKEN_INPUT]: { + policyID: string; + }; [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_IMPORT]: { policyID: string; }; + [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_IMPORT_CUSTOMERS_OR_PROJECTS]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_IMPORT_CUSTOMERS_OR_PROJECTS_SELECT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_IMPORT_MAPPING]: { + policyID: string; + importField: TupleToUnion; + }; [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_EXPORT]: { policyID: string; }; @@ -463,6 +485,9 @@ type SettingsNavigatorParamList = { [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_PROVINCIAL_TAX_POSTING_ACCOUNT_SELECT]: { policyID: string; }; + [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_ADVANCED]: { + policyID: string; + }; [SCREENS.GET_ASSISTANCE]: { backTo: Routes; }; @@ -818,6 +843,27 @@ type TransactionDuplicateNavigatorParamList = { [SCREENS.TRANSACTION_DUPLICATE.REVIEW]: { threadReportID: string; }; + [SCREENS.TRANSACTION_DUPLICATE.MERCHANT]: { + threadReportID: string; + }; + [SCREENS.TRANSACTION_DUPLICATE.CATEGORY]: { + threadReportID: string; + }; + [SCREENS.TRANSACTION_DUPLICATE.TAG]: { + threadReportID: string; + }; + [SCREENS.TRANSACTION_DUPLICATE.DESCRIPTION]: { + threadReportID: string; + }; + [SCREENS.TRANSACTION_DUPLICATE.TAX_CODE]: { + threadReportID: string; + }; + [SCREENS.TRANSACTION_DUPLICATE.BILLABLE]: { + threadReportID: string; + }; + [SCREENS.TRANSACTION_DUPLICATE.REIMBURSABLE]: { + threadReportID: string; + }; }; type LeftModalNavigatorParamList = { diff --git a/src/libs/Network/enhanceParameters.ts b/src/libs/Network/enhanceParameters.ts index 712d76db927c..01d2185a34c6 100644 --- a/src/libs/Network/enhanceParameters.ts +++ b/src/libs/Network/enhanceParameters.ts @@ -1,8 +1,25 @@ +import Onyx from 'react-native-onyx'; import * as Environment from '@libs/Environment/Environment'; import getPlatform from '@libs/getPlatform'; import CONFIG from '@src/CONFIG'; +import ONYXKEYS from '@src/ONYXKEYS'; +import pkg from '../../../package.json'; import * as NetworkStore from './NetworkStore'; +// For all requests, we'll send the lastUpdateID that is applied to this client. This will +// allow us to calculate previousUpdateID faster. +let lastUpdateIDAppliedToClient = -1; +Onyx.connect({ + key: ONYXKEYS.ONYX_UPDATES_LAST_UPDATE_ID_APPLIED_TO_CLIENT, + callback: (value) => { + if (value) { + lastUpdateIDAppliedToClient = value; + } else { + lastUpdateIDAppliedToClient = -1; + } + }, +}); + /** * Does this command require an authToken? */ @@ -36,5 +53,9 @@ export default function enhanceParameters(command: string, parameters: Record { let payload = pushPayload.extras.payload; if (typeof payload === 'string') { - payload = JSON.parse(payload); + payload = JSON.parse(payload) as string; } const data = payload as PushNotificationData; return { @@ -34,7 +34,7 @@ const clearReportNotifications: ClearReportNotifications = (reportID: string) => Log.info(`[PushNotification] found ${reportNotificationIDs.length} notifications to clear`, false, {reportID}); reportNotificationIDs.forEach((notificationID) => Airship.push.clearNotification(notificationID)); }) - .catch((error) => { + .catch((error: unknown) => { Log.alert(`${CONST.ERROR.ENSURE_BUGBOT} [PushNotification] BrowserNotifications.clearReportNotifications threw an error. This should never happen.`, {reportID, error}); }); }; diff --git a/src/libs/OptionsListUtils.ts b/src/libs/OptionsListUtils.ts index b952fbe9af4e..fc73c85b0354 100644 --- a/src/libs/OptionsListUtils.ts +++ b/src/libs/OptionsListUtils.ts @@ -992,7 +992,7 @@ function sortCategories(categories: Record): Category[] { const sortedCategories = Object.values(categories).sort((a, b) => a.name.localeCompare(b.name)); // An object that respects nesting of categories. Also, can contain only uniq categories. - const hierarchy = {}; + const hierarchy: Hierarchy = {}; /** * Iterates over all categories to set each category in a proper place in hierarchy * It gets a path based on a category name e.g. "Parent: Child: Subcategory" -> "Parent.Child.Subcategory". @@ -1010,7 +1010,7 @@ function sortCategories(categories: Record): Category[] { */ sortedCategories.forEach((category) => { const path = category.name.split(CONST.PARENT_CHILD_SEPARATOR); - const existedValue = lodashGet(hierarchy, path, {}); + const existedValue = lodashGet(hierarchy, path, {}) as Hierarchy; lodashSet(hierarchy, path, { ...existedValue, name: category.name, @@ -1066,7 +1066,7 @@ function sortTags(tags: Record | Array | Category[], isOneLine = false, selectedOptionsName: string[] = []): OptionTree[] { +function getCategoryOptionTree(options: Record | Category[], isOneLine = false, selectedOptions: Category[] = []): OptionTree[] { const optionCollection = new Map(); Object.values(options).forEach((option) => { if (isOneLine) { @@ -1091,6 +1091,8 @@ function getCategoryOptionTree(options: Record | Category[], i const indents = times(index, () => CONST.INDENTS).join(''); const isChild = array.length - 1 === index; const searchText = array.slice(0, index + 1).join(CONST.PARENT_CHILD_SEPARATOR); + const selectedParentOption = !isChild && Object.values(selectedOptions).find((op) => op.name === searchText); + const isParentOptionDisabled = !selectedParentOption || !selectedParentOption.enabled || selectedParentOption.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; if (optionCollection.has(searchText)) { return; @@ -1101,8 +1103,8 @@ function getCategoryOptionTree(options: Record | Category[], i keyForList: searchText, searchText, tooltipText: optionName, - isDisabled: isChild ? !option.enabled || option.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE : !selectedOptionsName.includes(searchText), - isSelected: isChild ? !!option.isSelected : selectedOptionsName.includes(searchText), + isDisabled: isChild ? !option.enabled || option.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE : isParentOptionDisabled, + isSelected: isChild ? !!option.isSelected : !!selectedParentOption, pendingAction: option.pendingAction, }); }); @@ -1130,7 +1132,8 @@ function getCategoryListSections( selectedOptions.forEach((option) => { if (enabledCategoriesNames.includes(option.name)) { - selectedOptionsWithDisabledState.push({...option, isSelected: true, enabled: true}); + const categoryObj = enabledCategories.find((category) => category.name === option.name); + selectedOptionsWithDisabledState.push({...(categoryObj ?? option), isSelected: true, enabled: true}); return; } selectedOptionsWithDisabledState.push({...option, isSelected: true, enabled: false}); @@ -1190,7 +1193,7 @@ function getCategoryListSections( const filteredCategories = enabledCategories.filter((category) => !selectedOptionNames.includes(category.name)); if (numberOfEnabledCategories < CONST.CATEGORY_LIST_THRESHOLD) { - const data = getCategoryOptionTree(filteredCategories, false, selectedOptionNames); + const data = getCategoryOptionTree(filteredCategories, false, selectedOptionsWithDisabledState); categorySections.push({ // "All" section when items amount less than the threshold title: '', @@ -1225,7 +1228,7 @@ function getCategoryListSections( }); } - const data = getCategoryOptionTree(filteredCategories, false, selectedOptionNames); + const data = getCategoryOptionTree(filteredCategories, false, selectedOptionsWithDisabledState); categorySections.push({ // "All" section when items amount more than the threshold title: Localize.translateLocal('common.all'), diff --git a/src/libs/PaginationUtils.ts b/src/libs/PaginationUtils.ts new file mode 100644 index 000000000000..fe75b6fb9927 --- /dev/null +++ b/src/libs/PaginationUtils.ts @@ -0,0 +1,195 @@ +import CONST from '@src/CONST'; +import type Pages from '@src/types/onyx/Pages'; + +type PageWithIndex = { + /** The IDs we store in Onyx and which make up the page. */ + ids: string[]; + + /** The first ID in the page. */ + firstID: string; + + /** The index of the first ID in the page in the complete set of sorted items. */ + firstIndex: number; + + /** The last ID in the page. */ + lastID: string; + + /** The index of the last ID in the page in the complete set of sorted items. */ + lastIndex: number; +}; + +// It's useful to be able to reference and item along with its index in a sorted array, +// since the index is needed for ordering but the id is what we actually store. +type ItemWithIndex = { + id: string; + index: number; +}; + +/** + * Finds the id and index in sortedItems of the first item in the given page that's present in sortedItems. + */ +function findFirstItem(sortedItems: TResource[], page: string[], getID: (item: TResource) => string): ItemWithIndex | null { + for (const id of page) { + if (id === CONST.PAGINATION_START_ID) { + return {id, index: 0}; + } + const index = sortedItems.findIndex((item) => getID(item) === id); + if (index !== -1) { + return {id, index}; + } + } + return null; +} + +/** + * Finds the id and index in sortedItems of the last item in the given page that's present in sortedItems. + */ +function findLastItem(sortedItems: TResource[], page: string[], getID: (item: TResource) => string): ItemWithIndex | null { + for (let i = page.length - 1; i >= 0; i--) { + const id = page[i]; + if (id === CONST.PAGINATION_END_ID) { + return {id, index: sortedItems.length - 1}; + } + const index = sortedItems.findIndex((item) => getID(item) === id); + if (index !== -1) { + return {id, index}; + } + } + return null; +} + +/** + * Finds the index and id of the first and last items of each page in `sortedItems`. + */ +function getPagesWithIndexes(sortedItems: TResource[], pages: Pages, getID: (item: TResource) => string): PageWithIndex[] { + return pages + .map((page) => { + let firstItem = findFirstItem(sortedItems, page, getID); + let lastItem = findLastItem(sortedItems, page, getID); + + // If all actions in the page are not found it will be removed. + if (firstItem === null || lastItem === null) { + return null; + } + + // In case actions were reordered, we need to swap them. + if (firstItem.index > lastItem.index) { + const temp = firstItem; + firstItem = lastItem; + lastItem = temp; + } + + const ids = sortedItems.slice(firstItem.index, lastItem.index + 1).map((item) => getID(item)); + if (firstItem.id === CONST.PAGINATION_START_ID) { + ids.unshift(CONST.PAGINATION_START_ID); + } + if (lastItem.id === CONST.PAGINATION_END_ID) { + ids.push(CONST.PAGINATION_END_ID); + } + + return { + ids, + firstID: firstItem.id, + firstIndex: firstItem.index, + lastID: lastItem.id, + lastIndex: lastItem.index, + }; + }) + .filter((page): page is PageWithIndex => page !== null); +} + +/** + * Given a sorted array of items and an array of Pages of item IDs, find any overlapping pages and merge them together. + */ +function mergeAndSortContinuousPages(sortedItems: TResource[], pages: Pages, getItemID: (item: TResource) => string): Pages { + const pagesWithIndexes = getPagesWithIndexes(sortedItems, pages, getItemID); + if (pagesWithIndexes.length === 0) { + return []; + } + + // Pages need to be sorted by firstIndex ascending then by lastIndex descending + const sortedPages = pagesWithIndexes.sort((a, b) => { + if (a.firstIndex !== b.firstIndex || a.firstID !== b.firstID) { + if (a.firstID === CONST.PAGINATION_START_ID) { + return -1; + } + return a.firstIndex - b.firstIndex; + } + if (a.lastID === CONST.PAGINATION_END_ID) { + return 1; + } + return b.lastIndex - a.lastIndex; + }); + + const result = [sortedPages[0]]; + for (let i = 1; i < sortedPages.length; i++) { + const page = sortedPages[i]; + const prevPage = sortedPages[i - 1]; + + // Current page is inside the previous page, skip + if (page.lastIndex <= prevPage.lastIndex && page.lastID !== CONST.PAGINATION_END_ID) { + // eslint-disable-next-line no-continue + continue; + } + + // Current page overlaps with the previous page, merge. + // This happens if the ids from the current page and previous page are the same or if the indexes overlap + if (page.firstID === prevPage.lastID || page.firstIndex < prevPage.lastIndex) { + result[result.length - 1] = { + firstID: prevPage.firstID, + firstIndex: prevPage.firstIndex, + lastID: page.lastID, + lastIndex: page.lastIndex, + // Only add items from prevPage that are not included in page in case of overlap. + ids: prevPage.ids.slice(0, prevPage.ids.indexOf(page.firstID)).concat(page.ids), + }; + // eslint-disable-next-line no-continue + continue; + } + + // No overlap, add the current page as is. + result.push(page); + } + + return result.map((page) => page.ids); +} + +/** + * Returns the page of items that contains the item with the given ID, or the first page if null. + * See unit tests for example of inputs and expected outputs. + * + * Note: sortedItems should be sorted in descending order. + */ +function getContinuousChain(sortedItems: TResource[], pages: Pages, getID: (item: TResource) => string, id?: string): TResource[] { + if (pages.length === 0) { + return id ? [] : sortedItems; + } + + const pagesWithIndexes = getPagesWithIndexes(sortedItems, pages, getID); + + let page: PageWithIndex; + + if (id) { + const index = sortedItems.findIndex((item) => getID(item) === id); + + // If we are linking to an action that doesn't exist in Onyx, return an empty array + if (index === -1) { + return []; + } + + const linkedPage = pagesWithIndexes.find((pageIndex) => index >= pageIndex.firstIndex && index <= pageIndex.lastIndex); + + // If we are linked to an action in a gap return it by itself + if (!linkedPage) { + return [sortedItems[index]]; + } + + page = linkedPage; + } else { + page = pagesWithIndexes[0]; + } + + return page ? sortedItems.slice(page.firstIndex, page.lastIndex + 1) : sortedItems; +} + +export default {mergeAndSortContinuousPages, getContinuousChain}; diff --git a/src/libs/Permissions.ts b/src/libs/Permissions.ts index aafc38a9040b..faea5965fee4 100644 --- a/src/libs/Permissions.ts +++ b/src/libs/Permissions.ts @@ -60,10 +60,6 @@ function canUseNetSuiteUSATax(betas: OnyxEntry): boolean { return !!betas?.includes(CONST.BETAS.NETSUITE_USA_TAX) || canUseAllBetas(betas); } -function canUseCommentLinking(betas: OnyxEntry): boolean { - return !!betas?.includes(CONST.BETAS.COMMENT_LINKING) || canUseAllBetas(betas); -} - /** * Link previews are temporarily disabled. */ @@ -86,5 +82,4 @@ export default { canUseReportFieldsFeature, canUseWorkspaceFeeds, canUseNetSuiteUSATax, - canUseCommentLinking, }; diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts index 5bd496ab9d39..4c071317907b 100644 --- a/src/libs/PolicyUtils.ts +++ b/src/libs/PolicyUtils.ts @@ -2,6 +2,7 @@ import {Str} from 'expensify-common'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; +import type {LocaleContextProps} from '@components/LocaleContextProvider'; import type {SelectorType} from '@components/SelectionScreen'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -142,7 +143,8 @@ function shouldShowPolicy(policy: OnyxEntry, isOffline: boolean): boolea return ( !!policy && (policy?.type !== CONST.POLICY.TYPE.PERSONAL || !!policy?.isJoinRequestPending) && - (isOffline || policy?.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || Object.keys(policy.errors ?? {}).length > 0) + (isOffline || policy?.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || Object.keys(policy.errors ?? {}).length > 0) && + !!policy?.role ); } @@ -536,6 +538,33 @@ function canUseProvincialTaxNetSuite(subsidiaryCountry?: string) { return subsidiaryCountry === '_canada'; } +function getCustomersOrJobsLabelNetSuite(policy: Policy | undefined, translate: LocaleContextProps['translate']): string | undefined { + const importMapping = policy?.connections?.netsuite?.options?.config?.syncOptions?.mapping; + if (!importMapping?.customers && !importMapping?.jobs) { + return undefined; + } + const importFields: string[] = []; + const importCustomer = importMapping?.customers ?? CONST.INTEGRATION_ENTITY_MAP_TYPES.NETSUITE_DEFAULT; + const importJobs = importMapping?.jobs ?? CONST.INTEGRATION_ENTITY_MAP_TYPES.NETSUITE_DEFAULT; + + if (importCustomer === CONST.INTEGRATION_ENTITY_MAP_TYPES.NETSUITE_DEFAULT && importJobs === CONST.INTEGRATION_ENTITY_MAP_TYPES.NETSUITE_DEFAULT) { + return undefined; + } + + const importedValue = importMapping?.customers !== CONST.INTEGRATION_ENTITY_MAP_TYPES.NETSUITE_DEFAULT ? importCustomer : importJobs; + + if (importCustomer !== CONST.INTEGRATION_ENTITY_MAP_TYPES.NETSUITE_DEFAULT) { + importFields.push(translate('workspace.netsuite.import.customersOrJobs.customers')); + } + + if (importJobs !== CONST.INTEGRATION_ENTITY_MAP_TYPES.NETSUITE_DEFAULT) { + importFields.push(translate('workspace.netsuite.import.customersOrJobs.jobs')); + } + + const importedValueLabel = translate(`workspace.netsuite.import.customersOrJobs.label`, importFields, translate(`workspace.accounting.importTypes.${importedValue}`).toLowerCase()); + return importedValueLabel.charAt(0).toUpperCase() + importedValueLabel.slice(1); +} + function getIntegrationLastSuccessfulDate(connection?: Connections[keyof Connections]) { if (!connection) { return undefined; @@ -652,6 +681,7 @@ export { navigateWhenEnableFeature, getIntegrationLastSuccessfulDate, getCurrentConnectionName, + getCustomersOrJobsLabelNetSuite, }; export type {MemberEmailsToAccountIDs}; diff --git a/src/libs/Pusher/pusher.ts b/src/libs/Pusher/pusher.ts index 25641d985042..a3383dbadb8a 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 as string); + data = isObject(eventData) ? eventData : (JSON.parse(eventData) as EventData); } 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 7353e00612c8..9aef307de512 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -24,6 +24,7 @@ import Parser from './Parser'; import * as PersonalDetailsUtils from './PersonalDetailsUtils'; import type {OptimisticIOUReportAction, PartialReportAction} from './ReportUtils'; import StringUtils from './StringUtils'; +// eslint-disable-next-line import/no-cycle import * as TransactionUtils from './TransactionUtils'; type LastVisibleMessage = { @@ -350,27 +351,12 @@ function getSortedReportActions(reportActions: ReportAction[] | null, shouldSort return sortedActions; } -function isOptimisticAction(reportAction: ReportAction) { - return ( - !!reportAction.isOptimisticAction || - reportAction.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD || - reportAction.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE - ); -} - -function shouldIgnoreGap(currentReportAction: ReportAction | undefined, nextReportAction: ReportAction | undefined) { - if (!currentReportAction || !nextReportAction) { - return false; - } - return ( - isOptimisticAction(currentReportAction) || - isOptimisticAction(nextReportAction) || - !!getWhisperedTo(currentReportAction).length || - !!getWhisperedTo(nextReportAction).length || - currentReportAction.actionName === CONST.REPORT.ACTIONS.TYPE.ROOM_CHANGE_LOG.INVITE_TO_ROOM || - nextReportAction.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED || - nextReportAction.actionName === CONST.REPORT.ACTIONS.TYPE.CLOSED - ); +/** + * Returns filtered list for one transaction view as we don't want to display IOU action type in the one-transaction view + * Separated it from getCombinedReportActions, so it can be reused + */ +function getFilteredForOneTransactionView(reportActions: ReportAction[]): ReportAction[] { + return reportActions.filter((action) => !isSentMoneyReportAction(action)); } /** @@ -409,51 +395,6 @@ function getCombinedReportActions( return getSortedReportActions(filteredReportActions, true); } -/** - * Returns the largest gapless range of reportActions including a the provided reportActionID, where a "gap" is defined as a reportAction's `previousReportActionID` not matching the previous reportAction in the sortedReportActions array. - * See unit tests for example of inputs and expected outputs. - * Note: sortedReportActions sorted in descending order - */ -function getContinuousReportActionChain(sortedReportActions: ReportAction[], id?: string): ReportAction[] { - let index; - - if (id) { - index = sortedReportActions.findIndex((reportAction) => reportAction.reportActionID === id); - } else { - index = sortedReportActions.findIndex((reportAction) => !isOptimisticAction(reportAction)); - } - - if (index === -1) { - // if no non-pending action is found, that means all actions on the report are optimistic - // in this case, we'll assume the whole chain of reportActions is continuous and return it in its entirety - return id ? [] : sortedReportActions; - } - - let startIndex = index; - let endIndex = index; - - // Iterate forwards through the array, starting from endIndex. i.e: newer to older - // This loop checks the continuity of actions by comparing the current item's previousReportActionID with the next item's reportActionID. - // It ignores optimistic actions, whispers and InviteToRoom actions - while ( - (endIndex < sortedReportActions.length - 1 && sortedReportActions[endIndex].previousReportActionID === sortedReportActions[endIndex + 1].reportActionID) || - shouldIgnoreGap(sortedReportActions[endIndex], sortedReportActions[endIndex + 1]) - ) { - endIndex++; - } - - // Iterate backwards through the sortedReportActions, starting from startIndex. (older to newer) - // This loop ensuress continuity in a sequence of actions by comparing the current item's reportActionID with the previous item's previousReportActionID. - while ( - (startIndex > 0 && sortedReportActions[startIndex].reportActionID === sortedReportActions[startIndex - 1].previousReportActionID) || - shouldIgnoreGap(sortedReportActions[startIndex], sortedReportActions[startIndex - 1]) - ) { - startIndex--; - } - - return sortedReportActions.slice(startIndex, endIndex + 1); -} - /** * Finds most recent IOU request action ID. */ @@ -1510,7 +1451,6 @@ export { shouldReportActionBeVisible, shouldHideNewMarker, shouldReportActionBeVisibleAsLastAction, - getContinuousReportActionChain, hasRequestFromCurrentAccount, getFirstVisibleReportActionID, isMemberChangeAction, @@ -1545,6 +1485,7 @@ export { getTextFromHtml, isTripPreview, getIOUActionForReportID, + getFilteredForOneTransactionView, }; export type {LastVisibleMessage}; diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 9a21542d6375..3799a7fe8240 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -902,20 +902,11 @@ function isTripRoom(report: OnyxEntry): boolean { return isChatReport(report) && getChatType(report) === CONST.REPORT.CHAT_TYPE.TRIP_ROOM; } -function isIndividualInvoiceRoom(report: OnyxEntry): boolean { - return isInvoiceRoom(report) && report?.invoiceReceiver?.type === CONST.REPORT.INVOICE_RECEIVER_TYPE.INDIVIDUAL; -} - function isCurrentUserInvoiceReceiver(report: OnyxEntry): boolean { if (report?.invoiceReceiver?.type === CONST.REPORT.INVOICE_RECEIVER_TYPE.INDIVIDUAL) { return currentUserAccountID === report.invoiceReceiver.accountID; } - if (report?.invoiceReceiver?.type === CONST.REPORT.INVOICE_RECEIVER_TYPE.BUSINESS) { - const policy = PolicyUtils.getPolicy(report.invoiceReceiver.policyID); - return PolicyUtils.isPolicyAdmin(policy); - } - return false; } @@ -1454,6 +1445,22 @@ function isMoneyRequestReport(reportOrID: OnyxInputOrEntry | string): bo return isIOUReport(report) || isExpenseReport(report); } +/** + * Checks if a report contains only Non-Reimbursable transactions + */ +function hasOnlyNonReimbursableTransactions(iouReportID: string | undefined): boolean { + if (!iouReportID) { + return false; + } + + const transactions = TransactionUtils.getAllReportTransactions(iouReportID); + if (!transactions || transactions.length === 0) { + return false; + } + + return transactions.every((transaction) => !TransactionUtils.getReimbursable(transaction)); +} + /** * Checks if a report has only one transaction associated with it */ @@ -1553,6 +1560,11 @@ function canAddOrDeleteTransactions(moneyRequestReport: OnyxEntry): bool return false; } + const policy = getPolicy(moneyRequestReport?.policyID); + if (PolicyUtils.isInstantSubmitEnabled(policy) && PolicyUtils.isSubmitAndClose(policy) && hasOnlyNonReimbursableTransactions(moneyRequestReport?.reportID)) { + return false; + } + if (isReportApproved(moneyRequestReport) || isSettled(moneyRequestReport?.reportID)) { return false; } @@ -1897,6 +1909,7 @@ function getParticipantsAccountIDsForDisplay(report: OnyxEntry, shouldEx if (shouldExcludeDeleted && report?.pendingChatMembers?.findLast((member) => member.accountID === accountID)?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { return false; } + return true; }); } @@ -1986,7 +1999,7 @@ function getIcons( if (isChatThread(report)) { const parentReportAction = ReportActionsUtils.getParentReportAction(report); - const actorAccountID = parentReportAction?.actorAccountID; + const actorAccountID = getReportActionActorAccountID(parentReportAction, report); const actorDisplayName = PersonalDetailsUtils.getDisplayNameOrDefault(allPersonalDetails?.[actorAccountID ?? -1], '', false); const actorIcon = { id: actorAccountID, @@ -2037,15 +2050,9 @@ function getIcons( if (report?.invoiceReceiver?.type === CONST.REPORT.INVOICE_RECEIVER_TYPE.INDIVIDUAL) { icons.push(...getIconsForParticipants([report?.invoiceReceiver.accountID], personalDetails)); } else { - const receiverPolicyID = report?.invoiceReceiver?.policyID; - const receiverPolicy = getPolicy(receiverPolicyID); + const receiverPolicy = getPolicy(report?.invoiceReceiver?.policyID); if (!isEmptyObject(receiverPolicy)) { - icons.push({ - source: receiverPolicy?.avatarURL ?? getDefaultWorkspaceAvatar(receiverPolicy.name), - type: CONST.ICON_TYPE_WORKSPACE, - name: receiverPolicy.name, - id: receiverPolicyID, - }); + icons.push(getWorkspaceIcon(report, receiverPolicy)); } } } @@ -2117,16 +2124,10 @@ function getIcons( return icons; } - const receiverPolicyID = invoiceRoomReport?.invoiceReceiver?.policyID; - const receiverPolicy = getPolicy(receiverPolicyID); + const receiverPolicy = getPolicy(invoiceRoomReport?.invoiceReceiver?.policyID); if (!isEmptyObject(receiverPolicy)) { - icons.push({ - source: receiverPolicy?.avatarURL ?? getDefaultWorkspaceAvatar(receiverPolicy.name), - type: CONST.ICON_TYPE_WORKSPACE, - name: receiverPolicy.name, - id: receiverPolicyID, - }); + icons.push(getWorkspaceIcon(invoiceRoomReport, receiverPolicy)); } return icons; @@ -2591,16 +2592,7 @@ function getMoneyRequestReportName(report: OnyxEntry, policy?: OnyxEntry const moneyRequestTotal = getMoneyRequestSpendBreakdown(report).totalDisplaySpend; const formattedAmount = CurrencyUtils.convertToDisplayString(moneyRequestTotal, report?.currency); - let payerOrApproverName; - if (isExpenseReport(report)) { - payerOrApproverName = getPolicyName(report, false, policy); - } else if (isInvoiceReport(report)) { - const chatReport = getReportOrDraftReport(report?.chatReportID); - payerOrApproverName = getInvoicePayerName(chatReport); - } else { - payerOrApproverName = getDisplayNameForParticipant(report?.managerID) ?? ''; - } - + let payerOrApproverName = isExpenseReport(report) ? getPolicyName(report, false, policy) : getDisplayNameForParticipant(report?.managerID) ?? ''; const payerPaidAmountMessage = Localize.translateLocal('iou.payerPaidAmount', { payer: payerOrApproverName, amount: formattedAmount, @@ -4007,77 +3999,11 @@ function buildOptimisticExpenseReport(chatReportID: string, policyID: string, pa return expenseReport; } -function getIOUSubmittedMessage(report: OnyxEntry) { - const policy = getPolicy(report?.policyID); - - if (report?.ownerAccountID !== currentUserAccountID && policy?.role === CONST.POLICY.ROLE.ADMIN) { - const ownerPersonalDetail = getPersonalDetailsForAccountID(report?.ownerAccountID ?? -1); - const ownerDisplayName = `${ownerPersonalDetail.displayName ?? ''}${ownerPersonalDetail.displayName !== ownerPersonalDetail.login ? ` (${ownerPersonalDetail.login})` : ''}`; - - return [ - { - style: 'normal', - text: 'You (on behalf of ', - type: CONST.REPORT.MESSAGE.TYPE.TEXT, - }, - { - style: 'strong', - text: ownerDisplayName, - type: CONST.REPORT.MESSAGE.TYPE.TEXT, - }, - { - style: 'normal', - text: ' via admin-submit)', - type: CONST.REPORT.MESSAGE.TYPE.TEXT, - }, - { - style: 'normal', - text: ' submitted this report', - type: CONST.REPORT.MESSAGE.TYPE.TEXT, - }, - { - style: 'normal', - text: ' to ', - type: CONST.REPORT.MESSAGE.TYPE.TEXT, - }, - { - style: 'strong', - text: 'you', - type: CONST.REPORT.MESSAGE.TYPE.TEXT, - }, - ]; - } - - const submittedToPersonalDetail = getPersonalDetailsForAccountID(PolicyUtils.getSubmitToAccountID(policy, report?.ownerAccountID ?? 0)); - let submittedToDisplayName = `${submittedToPersonalDetail.displayName ?? ''}${ - submittedToPersonalDetail.displayName !== submittedToPersonalDetail.login ? ` (${submittedToPersonalDetail.login})` : '' - }`; - if (submittedToPersonalDetail?.accountID === currentUserAccountID) { - submittedToDisplayName = 'yourself'; - } - - return [ - { - type: CONST.REPORT.MESSAGE.TYPE.TEXT, - style: 'strong', - text: 'You', - }, - { - type: CONST.REPORT.MESSAGE.TYPE.TEXT, - style: 'normal', - text: ' submitted this report', - }, - { - type: CONST.REPORT.MESSAGE.TYPE.TEXT, - style: 'normal', - text: ' to ', - }, - { - type: CONST.REPORT.MESSAGE.TYPE.TEXT, - style: 'strong', - text: submittedToDisplayName, - }, - ]; +function getIOUSubmittedMessage(reportID: string) { + const report = getReportOrDraftReport(reportID); + const linkedReport = isChatThread(report) ? getParentReport(report) : report; + const formattedAmount = CurrencyUtils.convertToDisplayString(Math.abs(linkedReport?.total ?? 0), linkedReport?.currency); + return Localize.translateLocal('iou.submittedAmount', {formattedAmount}); } /** @@ -4091,11 +4017,6 @@ function getIOUSubmittedMessage(report: OnyxEntry) { */ function getIOUReportActionMessage(iouReportID: string, type: string, total: number, comment: string, currency: string, paymentType = '', isSettlingUp = false): Message[] { const report = getReportOrDraftReport(iouReportID); - - if (type === CONST.REPORT.ACTIONS.TYPE.SUBMITTED) { - return getIOUSubmittedMessage(!isEmptyObject(report) ? report : undefined); - } - const amount = type === CONST.IOU.REPORT_ACTION_TYPE.PAY && !isEmptyObject(report) ? CurrencyUtils.convertToDisplayString(getMoneyRequestSpendBreakdown(report).totalDisplaySpend, currency) @@ -4132,6 +4053,9 @@ function getIOUReportActionMessage(iouReportID: string, type: string, total: num case CONST.IOU.REPORT_ACTION_TYPE.PAY: iouMessage = isSettlingUp ? `paid ${amount}${paymentMethodMessage}` : `sent ${amount}${comment && ` for ${comment}`}${paymentMethodMessage}`; break; + case CONST.REPORT.ACTIONS.TYPE.SUBMITTED: + iouMessage = Localize.translateLocal('iou.submittedAmount', {formattedAmount: amount}); + break; default: break; } @@ -4431,7 +4355,6 @@ function buildOptimisticActionableTrackExpenseWhisper(iouAction: OptimisticIOURe type: 'TEXT', }, ], - previousReportActionID: iouAction?.reportActionID, reportActionID, shouldShow: true, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, @@ -5598,7 +5521,6 @@ function getChatByParticipants(newParticipantList: number[], reports: OnyxCollec isChatThread(report) || isTaskReport(report) || isMoneyRequestReport(report) || - isInvoiceReport(report) || isChatRoom(report) || isPolicyExpenseChat(report) || (isGroupChat(report) && !shouldIncludeGroupChats) @@ -6918,6 +6840,12 @@ function isNonAdminOrOwnerOfPolicyExpenseChat(report: OnyxInputOrEntry, return isPolicyExpenseChat(report) && !(PolicyUtils.isPolicyAdmin(policy) || PolicyUtils.isPolicyOwner(policy, currentUserAccountID ?? -1) || isReportOwner(report)); } +function isAdminOwnerApproverOrReportOwner(report: OnyxEntry, policy: OnyxEntry): boolean { + const isApprover = isMoneyRequestReport(report) && report?.managerID !== null && currentUserPersonalDetails?.accountID === report?.managerID; + + return PolicyUtils.isPolicyAdmin(policy) || PolicyUtils.isPolicyOwner(policy, currentUserAccountID ?? -1) || isReportOwner(report) || isApprover; +} + /** * Whether the user can join a report */ @@ -7194,6 +7122,7 @@ export { getGroupChatName, getIOUReportActionDisplayMessage, getIOUReportActionMessage, + getIOUSubmittedMessage, getIcons, getIconsForParticipants, getIndicatedMissingPaymentMethod, @@ -7377,11 +7306,12 @@ export { isCurrentUserInvoiceReceiver, isDraftReport, changeMoneyRequestHoldStatus, + isAdminOwnerApproverOrReportOwner, createDraftWorkspaceAndNavigateToConfirmationScreen, isChatUsedForOnboarding, getChatUsedForOnboarding, findPolicyExpenseChatByPolicyID, - isIndividualInvoiceRoom, + hasOnlyNonReimbursableTransactions, }; export type { diff --git a/src/libs/SearchUtils.ts b/src/libs/SearchUtils.ts index cd641cdc8c90..cb579e44b95d 100644 --- a/src/libs/SearchUtils.ts +++ b/src/libs/SearchUtils.ts @@ -6,6 +6,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type * as OnyxTypes from '@src/types/onyx'; import type {SearchAccountDetails, SearchDataTypes, SearchPersonalDetails, SearchTransaction, SearchTypeToItemMap, SectionsType} from '@src/types/onyx/SearchResults'; +import type SearchResults from '@src/types/onyx/SearchResults'; import DateUtils from './DateUtils'; import getTopmostCentralPaneRoute from './Navigation/getTopmostCentralPaneRoute'; import navigationRef from './Navigation/navigationRef'; @@ -166,6 +167,7 @@ function getReportSections(data: OnyxTypes.SearchResults['data'], metadata: Onyx reportIDToTransactions[reportKey] = { ...value, + keyForList: value.reportID, from: data.personalDetailsList?.[value.accountID], to: data.personalDetailsList?.[value.managerID], transactions, @@ -298,5 +300,21 @@ function getSearchParams() { return topmostCentralPaneRoute?.params as AuthScreensParamList['Search_Central_Pane']; } -export {getListItem, getQueryHash, getSections, getSortedSections, getShouldShowMerchant, getSearchType, getSearchParams, shouldShowYear, isReportListItemType, isTransactionListItemType}; +function isSearchResultsEmpty(searchResults: SearchResults) { + return !Object.keys(searchResults?.data).some((key) => key.startsWith(ONYXKEYS.COLLECTION.TRANSACTION)); +} + +export { + getListItem, + getQueryHash, + getSections, + getSortedSections, + getShouldShowMerchant, + getSearchType, + getSearchParams, + shouldShowYear, + isReportListItemType, + isTransactionListItemType, + isSearchResultsEmpty, +}; export type {SearchColumnType, SortOrder}; diff --git a/src/libs/SidebarUtils.ts b/src/libs/SidebarUtils.ts index b7d365a103ae..3f7ee1b167c2 100644 --- a/src/libs/SidebarUtils.ts +++ b/src/libs/SidebarUtils.ts @@ -390,28 +390,33 @@ function getOptionData({ } } else { if (!lastMessageText) { - // Here we get the beginning of chat history message and append the display name for each user, adding pronouns if there are any. - // We also add a fullstop after the final name, the word "and" before the final name and commas between all previous names. - lastMessageText = ReportUtils.isSelfDM(report) - ? Localize.translate(preferredLocale, 'reportActionsView.beginningOfChatHistorySelfDM') - : Localize.translate(preferredLocale, 'reportActionsView.beginningOfChatHistory') + - displayNamesWithTooltips - .map(({displayName, pronouns}, index) => { - const formattedText = !pronouns ? displayName : `${displayName} (${pronouns})`; - - if (index === displayNamesWithTooltips.length - 1) { - return `${formattedText}.`; - } - if (index === displayNamesWithTooltips.length - 2) { - return `${formattedText} ${Localize.translate(preferredLocale, 'common.and')}`; - } - if (index < displayNamesWithTooltips.length - 2) { - return `${formattedText},`; - } - - return ''; - }) - .join(' '); + if (ReportUtils.isSystemChat(report)) { + lastMessageText = Localize.translate(preferredLocale, 'reportActionsView.beginningOfChatHistorySystemDM'); + } else if (ReportUtils.isSelfDM(report)) { + lastMessageText = Localize.translate(preferredLocale, 'reportActionsView.beginningOfChatHistorySelfDM'); + } else { + // Here we get the beginning of chat history message and append the display name for each user, adding pronouns if there are any. + // We also add a fullstop after the final name, the word "and" before the final name and commas between all previous names. + lastMessageText = + Localize.translate(preferredLocale, 'reportActionsView.beginningOfChatHistory') + + displayNamesWithTooltips + .map(({displayName, pronouns}, index) => { + const formattedText = !pronouns ? displayName : `${displayName} (${pronouns})`; + + if (index === displayNamesWithTooltips.length - 1) { + return `${formattedText}.`; + } + if (index === displayNamesWithTooltips.length - 2) { + return `${formattedText} ${Localize.translate(preferredLocale, 'common.and')}`; + } + if (index < displayNamesWithTooltips.length - 2) { + return `${formattedText},`; + } + + return ''; + }) + .join(' '); + } } result.alternateText = diff --git a/src/libs/SubscriptionUtils.ts b/src/libs/SubscriptionUtils.ts index c807d0ca4a7e..9a4d1d1c5d28 100644 --- a/src/libs/SubscriptionUtils.ts +++ b/src/libs/SubscriptionUtils.ts @@ -351,7 +351,7 @@ function hasSubscriptionRedDotError(): boolean { * @returns Whether there is a subscription green dot info. */ function hasSubscriptionGreenDotInfo(): boolean { - return !getSubscriptionStatus()?.isError ?? false; + return getSubscriptionStatus()?.isError === false; } /** diff --git a/src/libs/TransactionUtils.ts b/src/libs/TransactionUtils.ts index b28e5b782965..8e1854950715 100644 --- a/src/libs/TransactionUtils.ts +++ b/src/libs/TransactionUtils.ts @@ -1,10 +1,11 @@ import lodashHas from 'lodash/has'; +import lodashIsEqual from 'lodash/isEqual'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {Beta, OnyxInputOrEntry, Policy, RecentWaypoint, Report, TaxRate, TaxRates, Transaction, TransactionViolation, TransactionViolations} from '@src/types/onyx'; +import type {Beta, OnyxInputOrEntry, Policy, RecentWaypoint, Report, ReviewDuplicates, 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'; @@ -14,6 +15,8 @@ import * as Localize from './Localize'; import * as NumberUtils from './NumberUtils'; import Permissions from './Permissions'; import {getCleanedTagName, getCustomUnitRate} from './PolicyUtils'; +// eslint-disable-next-line import/no-cycle +import * as ReportActionsUtils from './ReportActionsUtils'; let allTransactions: OnyxCollection = {}; Onyx.connect({ @@ -387,6 +390,13 @@ function getDistance(transaction: OnyxInputOrEntry): number { return transaction?.comment?.customUnit?.quantity ?? 0; } +/** + * Return the reimbursable value. Defaults to true to match BE logic. + */ +function getReimbursable(transaction: Transaction): boolean { + return transaction?.reimbursable ?? true; +} + /** * Return the mccGroup field from the transaction, return the modifiedMCCGroup if present. */ @@ -810,6 +820,101 @@ function getTransaction(transactionID: string): OnyxEntry { return allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]; } +type FieldsToCompare = Record>; +type FieldsToChange = { + category?: Array; + merchant?: Array; + tag?: Array; + description?: Array; + taxCode?: Array; + billable?: Array; + reimbursable?: Array; +}; + +/** + * This function compares fields of duplicate transactions and determines which fields should be kept and which should be changed. + * + * @returns An object with two properties: 'keep' and 'change'. + * 'keep' is an object where each key is a field name and the value is the value of that field in the transaction that should be kept. + * 'change' is an object where each key is a field name and the value is an array of different values of that field in the duplicate transactions. + * + * The function works as follows: + * 1. It fetches the transaction violations for the given transaction ID. + * 2. It finds the duplicate transactions. + * 3. It creates two empty objects, 'keep' and 'change'. + * 4. It defines the fields to compare in the transactions. + * 5. It iterates over the fields to compare. For each field: + * - If the field is 'description', it checks if all comments are equal, exist, or are empty. If so, it keeps the first transaction's comment. Otherwise, it finds the different values and adds them to 'change'. + * - For other fields, it checks if all fields are equal. If so, it keeps the first transaction's field value. Otherwise, it finds the different values and adds them to 'change'. + * 6. It returns the 'keep' and 'change' objects. + */ + +function compareDuplicateTransactionFields(transactionID: string): {keep: Partial; change: FieldsToChange} { + const transactionViolations = allTransactionViolations?.[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`]; + const duplicates = transactionViolations?.find((violation) => violation.name === CONST.VIOLATIONS.DUPLICATED_TRANSACTION)?.data?.duplicates ?? []; + const transactions = [transactionID, ...duplicates].map((item) => getTransaction(item)); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const keep: Record = {}; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const change: Record = {}; + + const fieldsToCompare: FieldsToCompare = { + merchant: ['merchant', 'modifiedMerchant'], + category: ['category'], + tag: ['tag'], + description: ['comment'], + taxCode: ['taxCode'], + billable: ['billable'], + reimbursable: ['reimbursable'], + }; + + const getDifferentValues = (items: Array>, keys: Array) => [...new Set(items.map((item) => keys.map((key) => item?.[key])).flat())]; + + for (const fieldName in fieldsToCompare) { + if (Object.prototype.hasOwnProperty.call(fieldsToCompare, fieldName)) { + const keys = fieldsToCompare[fieldName]; + const firstTransaction = transactions[0]; + const isFirstTransactionCommentEmptyObject = typeof firstTransaction?.comment === 'object' && firstTransaction?.comment.comment === ''; + + if (fieldName === 'description') { + const allCommentsAreEqual = transactions.every((item) => lodashIsEqual(item?.comment, firstTransaction?.comment)); + const allCommentsExist = transactions.every((item) => !!item?.comment.comment === !!firstTransaction?.comment.comment); + const allCommentsAreEmpty = isFirstTransactionCommentEmptyObject && transactions.every((item) => item?.comment === undefined); + + if (allCommentsAreEqual || allCommentsExist || allCommentsAreEmpty) { + keep[fieldName] = firstTransaction?.comment.comment ?? firstTransaction?.comment; + } else { + const differentValues = getDifferentValues(transactions, keys); + if (differentValues.length > 0) { + change[fieldName] = differentValues; + } + } + } else { + const allFieldsAreEqual = transactions.every((item) => keys.every((key) => item?.[key] === firstTransaction?.[key])); + + if (allFieldsAreEqual) { + keep[fieldName] = firstTransaction?.[keys[0]]; + } else { + const differentValues = getDifferentValues(transactions, keys); + if (differentValues.length > 0) { + change[fieldName] = differentValues; + } + } + } + } + } + + return {keep, change}; +} + +function getTransactionID(threadReportID: string): string { + const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${threadReportID}`] ?? null; + const parentReportAction = ReportActionsUtils.getReportAction(report?.parentReportID ?? '', report?.parentReportActionID ?? ''); + const IOUTransactionID = ReportActionsUtils.isMoneyRequestAction(parentReportAction) ? ReportActionsUtils.getOriginalMessage(parentReportAction)?.IOUTransactionID ?? '-1' : '-1'; + + return IOUTransactionID; +} + export { buildOptimisticTransaction, calculateTaxAmount, @@ -878,6 +983,9 @@ export { isCustomUnitRateIDForP2P, getRateID, getTransaction, + compareDuplicateTransactionFields, + getTransactionID, + getReimbursable, }; export type {TransactionChanges}; diff --git a/src/libs/__mocks__/Permissions.ts b/src/libs/__mocks__/Permissions.ts index 634626a507af..702aec6a7bd4 100644 --- a/src/libs/__mocks__/Permissions.ts +++ b/src/libs/__mocks__/Permissions.ts @@ -1,3 +1,4 @@ +import type Permissions from '@libs/Permissions'; import CONST from '@src/CONST'; import type Beta from '@src/types/onyx/Beta'; @@ -9,7 +10,7 @@ import type Beta from '@src/types/onyx/Beta'; */ export default { - ...jest.requireActual('../Permissions'), + ...jest.requireActual('../Permissions'), canUseDefaultRooms: (betas: Beta[]) => betas.includes(CONST.BETAS.DEFAULT_ROOMS), canUseViolations: (betas: Beta[]) => betas.includes(CONST.BETAS.VIOLATIONS), }; diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts index 42381d9008a7..48c70021cacc 100644 --- a/src/libs/actions/IOU.ts +++ b/src/libs/actions/IOU.ts @@ -291,12 +291,6 @@ Onyx.connect({ }, }); -let primaryPolicyID: OnyxEntry; -Onyx.connect({ - key: ONYXKEYS.NVP_ACTIVE_POLICY_ID, - callback: (value) => (primaryPolicyID = value), -}); - /** * Get the report or draft report given a reportID */ @@ -5944,22 +5938,13 @@ function getSendMoneyParams( } function getPayMoneyRequestParams( - initialChatReport: OnyxTypes.Report, + chatReport: OnyxTypes.Report, iouReport: OnyxTypes.Report, recipient: Participant, paymentMethodType: PaymentMethodType, full: boolean, - payAsBusiness?: boolean, ): PayMoneyRequestData { const isInvoiceReport = ReportUtils.isInvoiceReport(iouReport); - let chatReport = initialChatReport; - - if (ReportUtils.isIndividualInvoiceRoom(chatReport) && payAsBusiness && primaryPolicyID) { - const existingB2BInvoiceRoom = ReportUtils.getInvoiceChatByParticipants(chatReport.policyID ?? '', primaryPolicyID); - if (existingB2BInvoiceRoom) { - chatReport = existingB2BInvoiceRoom; - } - } let total = (iouReport.total ?? 0) - (iouReport.nonReimbursableTotal ?? 0); if (ReportUtils.hasHeldExpenses(iouReport.reportID) && !full && !!iouReport.unheldTotal) { @@ -5992,27 +5977,19 @@ function getPayMoneyRequestParams( optimisticNextStep = NextStepUtils.buildNextStep(iouReport, CONST.REPORT.STATUS_NUM.REIMBURSED, {isPaidWithExpensify: paymentMethodType === CONST.IOU.PAYMENT_TYPE.VBBA}); } - const optimisticChatReport = { - ...chatReport, - lastReadTime: DateUtils.getDBTime(), - lastVisibleActionCreated: optimisticIOUReportAction.created, - hasOutstandingChildRequest: false, - iouReportID: null, - lastMessageText: ReportActionsUtils.getReportActionText(optimisticIOUReportAction), - lastMessageHtml: ReportActionsUtils.getReportActionHtml(optimisticIOUReportAction), - }; - if (ReportUtils.isIndividualInvoiceRoom(chatReport) && payAsBusiness && primaryPolicyID) { - optimisticChatReport.invoiceReceiver = { - type: CONST.REPORT.INVOICE_RECEIVER_TYPE.BUSINESS, - policyID: primaryPolicyID, - }; - } - const optimisticData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT}${chatReport.reportID}`, - value: optimisticChatReport, + value: { + ...chatReport, + lastReadTime: DateUtils.getDBTime(), + lastVisibleActionCreated: optimisticIOUReportAction.created, + hasOutstandingChildRequest: false, + iouReportID: null, + lastMessageText: ReportActionsUtils.getReportActionText(optimisticIOUReportAction), + lastMessageHtml: ReportActionsUtils.getReportActionHtml(optimisticIOUReportAction), + }, }, { onyxMethod: Onyx.METHOD.MERGE, @@ -6634,20 +6611,19 @@ function payMoneyRequest(paymentType: PaymentMethodType, chatReport: OnyxTypes.R Navigation.dismissModalWithReport(chatReport); } -function payInvoice(paymentMethodType: PaymentMethodType, chatReport: OnyxTypes.Report, invoiceReport: OnyxTypes.Report, payAsBusiness = false) { +function payInvoice(paymentMethodType: PaymentMethodType, chatReport: OnyxTypes.Report, invoiceReport: OnyxTypes.Report) { const recipient = {accountID: invoiceReport.ownerAccountID}; const { optimisticData, successData, failureData, params: {reportActionID}, - } = getPayMoneyRequestParams(chatReport, invoiceReport, recipient, paymentMethodType, true, payAsBusiness); + } = getPayMoneyRequestParams(chatReport, invoiceReport, recipient, paymentMethodType, true); const params: PayInvoiceParams = { reportID: invoiceReport.reportID, reportActionID, paymentMethodType, - payAsBusiness, }; API.write(WRITE_COMMANDS.PAY_INVOICE, params, {optimisticData, successData, failureData}); diff --git a/src/libs/actions/OnyxUpdateManager/utils/__mocks__/index.ts b/src/libs/actions/OnyxUpdateManager/utils/__mocks__/index.ts index b4d97a4399db..f66e059ff7f6 100644 --- a/src/libs/actions/OnyxUpdateManager/utils/__mocks__/index.ts +++ b/src/libs/actions/OnyxUpdateManager/utils/__mocks__/index.ts @@ -3,7 +3,7 @@ import createProxyForObject from '@src/utils/createProxyForObject'; import type * as OnyxUpdateManagerUtilsImport from '..'; import {applyUpdates} from './applyUpdates'; -const UtilsImplementation: typeof OnyxUpdateManagerUtilsImport = jest.requireActual('@libs/actions/OnyxUpdateManager/utils'); +const UtilsImplementation = jest.requireActual('@libs/actions/OnyxUpdateManager/utils'); type OnyxUpdateManagerUtilsMockValues = { onValidateAndApplyDeferredUpdates: ((clientLastUpdateID?: number) => Promise) | undefined; diff --git a/src/libs/actions/PaymentMethods.ts b/src/libs/actions/PaymentMethods.ts index d4713e580b64..80c9b39141d8 100644 --- a/src/libs/actions/PaymentMethods.ts +++ b/src/libs/actions/PaymentMethods.ts @@ -13,7 +13,7 @@ import type { TransferWalletBalanceParams, UpdateBillingCurrencyParams, } from '@libs/API/parameters'; -import {READ_COMMANDS, SIDE_EFFECT_REQUEST_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; +import {READ_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; import * as CardUtils from '@libs/CardUtils'; import Navigation from '@libs/Navigation/Navigation'; import CONST from '@src/CONST'; @@ -253,25 +253,12 @@ function addSubscriptionPaymentCard(cardData: { }, ]; - if (currency === CONST.PAYMENT_CARD_CURRENCY.GBP) { - // eslint-disable-next-line rulesdir/no-api-side-effects-method - API.makeRequestWithSideEffects(SIDE_EFFECT_REQUEST_COMMANDS.ADD_PAYMENT_CARD_GBR, parameters, {optimisticData, successData, failureData}).then((response) => { - if (response?.jsonCode !== CONST.JSON_CODE.SUCCESS) { - return; - } - // TODO 3ds flow will be done as a part https://github.com/Expensify/App/issues/42432 - // We will use this onyx key to open Modal and preview iframe. Potentially we can save the whole object which come from side effect - Onyx.set(ONYXKEYS.VERIFY_3DS_SUBSCRIPTION, (response as {authenticationLink: string}).authenticationLink); - }); - } else { - // eslint-disable-next-line rulesdir/no-multiple-api-calls - API.write(WRITE_COMMANDS.ADD_PAYMENT_CARD, parameters, { - optimisticData, - successData, - failureData, - }); - Navigation.goBack(); - } + API.write(WRITE_COMMANDS.ADD_PAYMENT_CARD, parameters, { + optimisticData, + successData, + failureData, + }); + Navigation.goBack(); } /** diff --git a/src/libs/actions/Policy/Policy.ts b/src/libs/actions/Policy/Policy.ts index d075f8653d79..acd42b6202c7 100644 --- a/src/libs/actions/Policy/Policy.ts +++ b/src/libs/actions/Policy/Policy.ts @@ -19,7 +19,9 @@ import type { LeavePolicyParams, OpenDraftWorkspaceRequestParams, OpenPolicyExpensifyCardsPageParams, + OpenPolicyInitialPageParams, OpenPolicyMoreFeaturesPageParams, + OpenPolicyProfilePageParams, OpenPolicyTaxesPageParams, OpenPolicyWorkflowsPageParams, OpenWorkspaceInvitePageParams, @@ -35,6 +37,7 @@ import type { UpdateWorkspaceCustomUnitAndRateParams, UpdateWorkspaceDescriptionParams, UpdateWorkspaceGeneralSettingsParams, + UpgradeToCorporateParams, } from '@libs/API/parameters'; import type UpdatePolicyAddressParams from '@libs/API/parameters/UpdatePolicyAddressParams'; import {READ_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; @@ -187,7 +190,7 @@ function getPolicy(policyID: string | undefined): OnyxEntry { */ function getPrimaryPolicy(activePolicyID?: OnyxEntry): Policy | undefined { const activeAdminWorkspaces = PolicyUtils.getActiveAdminWorkspaces(allPolicies); - const primaryPolicy: Policy | null | undefined = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${activePolicyID}`]; + const primaryPolicy: Policy | null | undefined = allPolicies?.[activePolicyID ?? '-1']; return primaryPolicy ?? activeAdminWorkspaces[0]; } @@ -533,6 +536,10 @@ function clearNetSuiteErrorField(policyID: string, fieldName: string) { Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {connections: {netsuite: {options: {config: {errorFields: {[fieldName]: null}}}}}}); } +function clearNetSuiteAutoSyncErrorField(policyID: string) { + Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {connections: {netsuite: {config: {errorFields: {autoSync: null}}}}}); +} + function setWorkspaceReimbursement(policyID: string, reimbursementChoice: ValueOf, reimburserEmail: string) { const policy = getPolicy(policyID); @@ -2845,6 +2852,18 @@ function openPolicyMoreFeaturesPage(policyID: string) { API.read(READ_COMMANDS.OPEN_POLICY_MORE_FEATURES_PAGE, params); } +function openPolicyProfilePage(policyID: string) { + const params: OpenPolicyProfilePageParams = {policyID}; + + API.read(READ_COMMANDS.OPEN_POLICY_PROFILE_PAGE, params); +} + +function openPolicyInitialPage(policyID: string) { + const params: OpenPolicyInitialPageParams = {policyID}; + + API.read(READ_COMMANDS.OPEN_POLICY_INITIAL_PAGE, params); +} + function setPolicyCustomTaxName(policyID: string, customTaxName: string) { const policy = getPolicy(policyID); const originalCustomTaxName = policy?.taxRates?.name; @@ -3001,6 +3020,59 @@ function setForeignCurrencyDefault(policyID: string, taxCode: string) { API.write(WRITE_COMMANDS.SET_POLICY_TAXES_FOREIGN_CURRENCY_DEFAULT, parameters, onyxData); } +function upgradeToCorporate(policyID: string, featureName: string) { + const policy = getPolicy(policyID); + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `policy_${policyID}`, + value: { + isPendingUpgrade: true, + type: CONST.POLICY.TYPE.CORPORATE, + maxExpenseAge: CONST.POLICY.DEFAULT_MAX_EXPENSE_AGE, + maxExpenseAmount: CONST.POLICY.DEFAULT_MAX_EXPENSE_AMOUNT, + maxExpenseAmountNoReceipt: CONST.POLICY.DEFAULT_MAX_AMOUNT_NO_RECEIPT, + glCodes: true, + ...(PolicyUtils.isInstantSubmitEnabled(policy) && { + autoReporting: true, + autoReportingFrequency: CONST.POLICY.AUTO_REPORTING_FREQUENCIES.MANUAL, + }), + }, + }, + ]; + + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `policy_${policyID}`, + value: { + isPendingUpgrade: false, + }, + }, + ]; + + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `policy_${policyID}`, + value: { + isPendingUpgrade: false, + type: policy?.type, + maxExpenseAge: policy?.maxExpenseAge ?? null, + maxExpenseAmount: policy?.maxExpenseAmount ?? null, + maxExpenseAmountNoReceipt: policy?.maxExpenseAmountNoReceipt ?? null, + glCodes: policy?.glCodes ?? null, + autoReporting: policy?.autoReporting ?? null, + autoReportingFrequency: policy?.autoReportingFrequency ?? null, + }, + }, + ]; + + const parameters: UpgradeToCorporateParams = {policyID, featureName}; + + API.write(WRITE_COMMANDS.UPGRADE_TO_CORPORATE, parameters, {optimisticData, successData, failureData}); +} + function getPoliciesConnectedToSageIntacct(): Policy[] { return Object.values(allPolicies ?? {}).filter((policy): policy is Policy => !!policy && !!policy?.connections?.intacct); } @@ -3052,10 +3124,13 @@ export { enablePolicyWorkflows, enableDistanceRequestTax, openPolicyMoreFeaturesPage, + openPolicyProfilePage, + openPolicyInitialPage, generateCustomUnitID, clearQBOErrorField, clearXeroErrorField, clearNetSuiteErrorField, + clearNetSuiteAutoSyncErrorField, clearWorkspaceReimbursementErrors, setWorkspaceCurrencyDefault, setForeignCurrencyDefault, @@ -3067,6 +3142,7 @@ export { buildPolicyData, enableExpensifyCard, createPolicyExpenseChats, + upgradeToCorporate, openPolicyExpensifyCardsPage, requestExpensifyCardLimitIncrease, getPoliciesConnectedToSageIntacct, diff --git a/src/libs/actions/Policy/ReportFields.ts b/src/libs/actions/Policy/ReportField.ts similarity index 69% rename from src/libs/actions/Policy/ReportFields.ts rename to src/libs/actions/Policy/ReportField.ts index 220432cbc3c6..4a6c5ed4fad5 100644 --- a/src/libs/actions/Policy/ReportFields.ts +++ b/src/libs/actions/Policy/ReportField.ts @@ -1,7 +1,7 @@ import type {NullishDeep, OnyxCollection} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; import * as API from '@libs/API'; -import type {CreateWorkspaceReportFieldParams} from '@libs/API/parameters'; +import type {CreateWorkspaceReportFieldParams, PolicyReportFieldsReplace} from '@libs/API/parameters'; import {WRITE_COMMANDS} from '@libs/API/types'; import * as ErrorUtils from '@libs/ErrorUtils'; import * as ReportUtils from '@libs/ReportUtils'; @@ -10,7 +10,8 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {WorkspaceReportFieldsForm} from '@src/types/form/WorkspaceReportFieldsForm'; import INPUT_IDS from '@src/types/form/WorkspaceReportFieldsForm'; -import type {Policy, PolicyReportField} from '@src/types/onyx'; +import type {Policy, PolicyReportField, Report} from '@src/types/onyx'; +import type {OnyxValueWithOfflineFeedback} from '@src/types/onyx/OnyxCommon'; import type {OnyxData} from '@src/types/onyx/Request'; let listValues: string[]; @@ -34,7 +35,6 @@ Onyx.connect({ if (!key) { return; } - if (value === null || value === undefined) { // If we are deleting a policy, we have to check every report linked to that policy // and unset the draft indicator (pencil icon) alongside removing any draft comments. Clearing these values will keep the newly archived chats from being displayed in the LHN. @@ -136,7 +136,7 @@ function createReportField(policyID: string, {name, type, initialValue}: CreateR const previousFieldList = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]?.fieldList ?? {}; const fieldID = generateFieldID(name); const fieldKey = ReportUtils.getReportFieldKey(fieldID); - const newReportField: PolicyReportField = { + const newReportField: OnyxValueWithOfflineFeedback = { name, type, defaultValue: initialValue, @@ -149,6 +149,7 @@ function createReportField(policyID: string, {name, type, initialValue}: CreateR keys: [], externalIDs: [], isTax: false, + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, }; const onyxData: OnyxData = { optimisticData: [ @@ -159,9 +160,6 @@ function createReportField(policyID: string, {name, type, initialValue}: CreateR fieldList: { [fieldKey]: newReportField, }, - pendingFields: { - [fieldKey]: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, - }, errorFields: null, }, }, @@ -171,8 +169,8 @@ function createReportField(policyID: string, {name, type, initialValue}: CreateR key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, onyxMethod: Onyx.METHOD.MERGE, value: { - pendingFields: { - [fieldKey]: null, + fieldList: { + [fieldKey]: {pendingAction: null}, }, errorFields: null, }, @@ -186,9 +184,6 @@ function createReportField(policyID: string, {name, type, initialValue}: CreateR fieldList: { [fieldKey]: null, }, - pendingFields: { - [fieldKey]: null, - }, errorFields: { [fieldKey]: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('workspace.reportFields.genericFailureMessage'), }, @@ -204,6 +199,76 @@ function createReportField(policyID: string, {name, type, initialValue}: CreateR API.write(WRITE_COMMANDS.CREATE_WORKSPACE_REPORT_FIELD, parameters, onyxData); } +function deleteReportFields(policyID: string, reportFieldsToUpdate: string[]) { + const policy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]; + const allReportFields = policy?.fieldList ?? {}; + + const updatedReportFields = Object.fromEntries(Object.entries(allReportFields).filter(([key]) => !reportFieldsToUpdate.includes(key))); + const optimisticReportFields = reportFieldsToUpdate.reduce>>>((acc, reportFieldKey) => { + acc[reportFieldKey] = {pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE}; + return acc; + }, {}); + + const successReportFields = reportFieldsToUpdate.reduce>((acc, reportFieldKey) => { + acc[reportFieldKey] = null; + return acc; + }, {}); + + const failureReportFields = reportFieldsToUpdate.reduce>>>((acc, reportFieldKey) => { + acc[reportFieldKey] = {pendingAction: null}; + return acc; + }, {}); + + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + fieldList: optimisticReportFields, + }, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + fieldList: successReportFields, + errorFields: null, + }, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + fieldList: failureReportFields, + errorFields: { + fieldList: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'), + }, + }, + }, + ], + }; + + const parameters: PolicyReportFieldsReplace = { + policyID, + reportFields: JSON.stringify(Object.values(updatedReportFields)), + }; + + API.write(WRITE_COMMANDS.POLICY_REPORT_FIELDS_REPLACE, parameters, onyxData); +} + export type {CreateReportFieldArguments}; -export {setInitialCreateReportFieldsForm, createReportFieldsListValue, renameReportFieldsListValue, setReportFieldsListValueEnabled, deleteReportFieldsListValue, createReportField}; +export { + deleteReportFields, + setInitialCreateReportFieldsForm, + createReportFieldsListValue, + renameReportFieldsListValue, + setReportFieldsListValueEnabled, + deleteReportFieldsListValue, + createReportField, +}; diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index e3e157a68ed8..724db5ead649 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -58,6 +58,7 @@ import * as ErrorUtils from '@libs/ErrorUtils'; import isPublicScreenRoute from '@libs/isPublicScreenRoute'; import * as Localize from '@libs/Localize'; import Log from '@libs/Log'; +import {registerPaginationConfig} from '@libs/Middleware/Pagination'; import Navigation from '@libs/Navigation/Navigation'; import type {NetworkStatus} from '@libs/NetworkConnection'; import LocalNotification from '@libs/Notification/LocalNotification'; @@ -272,6 +273,17 @@ Onyx.connect({ callback: (val) => (quickAction = val), }); +registerPaginationConfig({ + initialCommand: WRITE_COMMANDS.OPEN_REPORT, + previousCommand: READ_COMMANDS.GET_OLDER_ACTIONS, + nextCommand: READ_COMMANDS.GET_NEWER_ACTIONS, + resourceCollectionKey: ONYXKEYS.COLLECTION.REPORT_ACTIONS, + pageCollectionKey: ONYXKEYS.COLLECTION.REPORT_ACTIONS_PAGES, + sortItems: (reportActions) => ReportActionsUtils.getSortedReportActionsForDisplay(reportActions, true), + getItemID: (reportAction) => reportAction.reportActionID, + isLastItem: (reportAction) => reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED, +}); + function clearGroupChat() { Onyx.set(ONYXKEYS.NEW_GROUP_CHAT_DRAFT, null); } @@ -941,14 +953,24 @@ function openReport( parameters.clientLastReadTime = currentReportData?.[reportID]?.lastReadTime ?? ''; + const paginationConfig = { + resourceID: reportID, + cursorID: reportActionID, + }; + if (isFromDeepLink) { - // eslint-disable-next-line rulesdir/no-api-side-effects-method - API.makeRequestWithSideEffects(SIDE_EFFECT_REQUEST_COMMANDS.OPEN_REPORT, parameters, {optimisticData, successData, failureData}).finally(() => { + API.paginate( + CONST.API_REQUEST_TYPE.MAKE_REQUEST_WITH_SIDE_EFFECTS, + SIDE_EFFECT_REQUEST_COMMANDS.OPEN_REPORT, + parameters, + {optimisticData, successData, failureData}, + paginationConfig, + ).finally(() => { Onyx.set(ONYXKEYS.IS_CHECKING_PUBLIC_ROOM, false); }); } else { // eslint-disable-next-line rulesdir/no-multiple-api-calls - API.write(WRITE_COMMANDS.OPEN_REPORT, parameters, {optimisticData, successData, failureData}); + API.paginate(CONST.API_REQUEST_TYPE.WRITE, WRITE_COMMANDS.OPEN_REPORT, parameters, {optimisticData, successData, failureData}, paginationConfig); } } @@ -1104,7 +1126,16 @@ function getOlderActions(reportID: string, reportActionID: string) { reportActionID, }; - API.read(READ_COMMANDS.GET_OLDER_ACTIONS, parameters, {optimisticData, successData, failureData}); + API.paginate( + CONST.API_REQUEST_TYPE.READ, + READ_COMMANDS.GET_OLDER_ACTIONS, + parameters, + {optimisticData, successData, failureData}, + { + resourceID: reportID, + cursorID: reportActionID, + }, + ); } /** @@ -1149,7 +1180,16 @@ function getNewerActions(reportID: string, reportActionID: string) { reportActionID, }; - API.read(READ_COMMANDS.GET_NEWER_ACTIONS, parameters, {optimisticData, successData, failureData}); + API.paginate( + CONST.API_REQUEST_TYPE.READ, + READ_COMMANDS.GET_NEWER_ACTIONS, + parameters, + {optimisticData, successData, failureData}, + { + resourceID: reportID, + cursorID: reportActionID, + }, + ); } /** diff --git a/src/libs/actions/Session/index.ts b/src/libs/actions/Session/index.ts index db78b94731ae..0c362f870da4 100644 --- a/src/libs/actions/Session/index.ts +++ b/src/libs/actions/Session/index.ts @@ -773,7 +773,7 @@ function authenticatePusher(socketID: string, channelName: string, callback: Cha Log.info('[PusherAuthorizer] Pusher authenticated successfully', false, {channelName}); callback(null, response as ChannelAuthorizationData); }) - .catch((error) => { + .catch((error: unknown) => { Log.hmmm('[PusherAuthorizer] Unhandled error: ', {channelName, error}); callback(new Error('AuthenticatePusher request failed'), {auth: ''}); }); diff --git a/src/libs/actions/Transaction.ts b/src/libs/actions/Transaction.ts index 3166d0dfcb8f..546c553ae672 100644 --- a/src/libs/actions/Transaction.ts +++ b/src/libs/actions/Transaction.ts @@ -13,7 +13,7 @@ import {buildOptimisticDismissedViolationReportAction} from '@libs/ReportUtils'; import * as TransactionUtils from '@libs/TransactionUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {PersonalDetails, RecentWaypoint, ReportAction, ReportActions, Transaction, TransactionViolation, TransactionViolations} from '@src/types/onyx'; +import type {PersonalDetails, RecentWaypoint, ReportAction, ReportActions, ReviewDuplicates, Transaction, TransactionViolation, TransactionViolations} from '@src/types/onyx'; import type {OnyxData} from '@src/types/onyx/Request'; import type {WaypointCollection} from '@src/types/onyx/Transaction'; @@ -358,6 +358,16 @@ function dismissDuplicateTransactionViolation(transactionIDs: string[], dissmiss failureData.push(...failureDataTransaction); failureData.push(...failureReportActions); + const successData: OnyxUpdate[] = transactionsReportActions.map((action, index) => ({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${action?.childReportID ?? '-1'}`, + value: { + [optimisticDissmidedViolationReportActions[index].reportActionID]: { + pendingAction: null, + }, + }, + })); + const params: DismissViolationParams = { name: CONST.VIOLATIONS.DUPLICATED_TRANSACTION, transactionIDList: transactionIDs.join(','), @@ -365,15 +375,14 @@ function dismissDuplicateTransactionViolation(transactionIDs: string[], dissmiss API.write(WRITE_COMMANDS.DISMISS_VIOLATION, params, { optimisticData, + successData, failureData, }); } -function setReviewDuplicatesKey(transactionID: string, transactionIDs: string[]) { +function setReviewDuplicatesKey(values: Partial) { Onyx.merge(`${ONYXKEYS.REVIEW_DUPLICATES}`, { - [transactionID]: { - duplicates: transactionIDs, - }, + ...values, }); } diff --git a/src/libs/actions/__mocks__/App.ts b/src/libs/actions/__mocks__/App.ts index 03744b397597..09fd553a87f3 100644 --- a/src/libs/actions/__mocks__/App.ts +++ b/src/libs/actions/__mocks__/App.ts @@ -5,7 +5,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type {OnyxUpdatesFromServer} from '@src/types/onyx'; import createProxyForObject from '@src/utils/createProxyForObject'; -const AppImplementation: typeof AppImport = jest.requireActual('@libs/actions/App'); +const AppImplementation = jest.requireActual('@libs/actions/App'); const { setLocale, setLocaleAndNavigate, @@ -39,7 +39,7 @@ const mockValues: AppMockValues = { }; const mockValuesProxy = createProxyForObject(mockValues); -const ApplyUpdatesImplementation: typeof ApplyUpdatesImport = jest.requireActual('@libs/actions/OnyxUpdateManager/utils/applyUpdates'); +const ApplyUpdatesImplementation = jest.requireActual('@libs/actions/OnyxUpdateManager/utils/applyUpdates'); const getMissingOnyxUpdates = jest.fn((_fromID: number, toID: number) => { if (mockValuesProxy.missingOnyxUpdatesToBeApplied === undefined) { return Onyx.set(ONYXKEYS.ONYX_UPDATES_LAST_UPDATE_ID_APPLIED_TO_CLIENT, toID); diff --git a/src/libs/actions/connections/NetSuiteCommands.ts b/src/libs/actions/connections/NetSuiteCommands.ts index 4d1a6617c253..20f7fcd6e483 100644 --- a/src/libs/actions/connections/NetSuiteCommands.ts +++ b/src/libs/actions/connections/NetSuiteCommands.ts @@ -2,6 +2,7 @@ import type {OnyxUpdate} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; import * as API from '@libs/API'; +import type {ConnectPolicyToNetSuiteParams} from '@libs/API/parameters'; import {WRITE_COMMANDS} from '@libs/API/types'; import * as ErrorUtils from '@libs/ErrorUtils'; import CONST from '@src/CONST'; @@ -14,6 +15,25 @@ type SubsidiaryParam = { subsidiary: string; }; +function connectPolicyToNetSuite(policyID: string, credentials: Omit) { + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_CONNECTION_SYNC_PROGRESS}${policyID}`, + value: { + stageInProgress: CONST.POLICY.CONNECTIONS.SYNC_STAGE_NAME.NETSUITE_SYNC_CONNECTION, + connectionName: CONST.POLICY.CONNECTIONS.NAME.NETSUITE, + timestamp: new Date().toISOString(), + }, + }, + ]; + const parameters: ConnectPolicyToNetSuiteParams = { + policyID, + ...credentials, + }; + API.write(WRITE_COMMANDS.CONNECT_POLICY_TO_NETSUITE, parameters, {optimisticData}); +} + function updateNetSuiteOnyxData( policyID: string, settingName: TSettingName, @@ -94,6 +114,92 @@ function updateNetSuiteOnyxData( + policyID: string, + settingName: TSettingName, + settingValue: Partial, + oldSettingValue: Partial, +) { + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + connections: { + netsuite: { + options: { + config: { + syncOptions: { + [settingName]: settingValue ?? null, + pendingFields: { + [settingName]: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + }, + errorFields: { + [settingName]: null, + }, + }, + }, + }, + }, + }, + }, + ]; + + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + connections: { + netsuite: { + options: { + config: { + syncOptions: { + [settingName]: oldSettingValue ?? null, + pendingFields: { + [settingName]: null, + }, + }, + errorFields: { + [settingName]: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'), + }, + }, + }, + }, + }, + }, + }, + ]; + + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + connections: { + netsuite: { + options: { + config: { + syncOptions: { + [settingName]: settingValue ?? null, + pendingFields: { + [settingName]: null, + }, + }, + errorFields: { + [settingName]: null, + }, + }, + }, + }, + }, + }, + }, + ]; + return {optimisticData, failureData, successData}; +} + function updateNetSuiteSubsidiary(policyID: string, newSubsidiary: SubsidiaryParam, oldSubsidiary: SubsidiaryParam) { const onyxData: OnyxData = { optimisticData: [ @@ -177,7 +283,12 @@ function updateNetSuiteSubsidiary(policyID: string, newSubsidiary: SubsidiaryPar API.write(WRITE_COMMANDS.UPDATE_NETSUITE_SUBSIDIARY, params, onyxData); } -function updateNetSuiteSyncTaxConfiguration(policyID: string, isSyncTaxEnabled: boolean) { +function updateNetSuiteImportMapping( + policyID: string, + mappingName: TMappingName, + mappingValue: ValueOf, + oldMappingValue?: ValueOf, +) { const onyxData: OnyxData = { optimisticData: [ { @@ -189,14 +300,15 @@ function updateNetSuiteSyncTaxConfiguration(policyID: string, isSyncTaxEnabled: options: { config: { syncOptions: { - syncTax: isSyncTaxEnabled, + mapping: { + [mappingName]: mappingValue, + pendingFields: { + [mappingName]: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + }, }, - // TODO: Fixing in the PR for Import Mapping https://github.com/Expensify/App/pull/44743 - // pendingFields: { - // syncTax: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, - // }, errorFields: { - syncTax: null, + [mappingName]: null, }, }, }, @@ -215,14 +327,15 @@ function updateNetSuiteSyncTaxConfiguration(policyID: string, isSyncTaxEnabled: options: { config: { syncOptions: { - syncTax: isSyncTaxEnabled, + mapping: { + [mappingName]: mappingValue, + pendingFields: { + [mappingName]: null, + }, + }, }, - // TODO: Fixing in the PR for Import Mapping https://github.com/Expensify/App/pull/44743 - // pendingFields: { - // syncTax: null - // }, errorFields: { - syncTax: null, + [mappingName]: null, }, }, }, @@ -241,13 +354,15 @@ function updateNetSuiteSyncTaxConfiguration(policyID: string, isSyncTaxEnabled: options: { config: { syncOptions: { - syncTax: !isSyncTaxEnabled, + mapping: { + [mappingName]: oldMappingValue, + pendingFields: { + [mappingName]: null, + }, + }, }, - // pendingFields: { - // syncTax: null, - // }, errorFields: { - syncTax: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'), + [mappingName]: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'), }, }, }, @@ -258,6 +373,38 @@ function updateNetSuiteSyncTaxConfiguration(policyID: string, isSyncTaxEnabled: ], }; + const params = { + policyID, + mapping: mappingValue, + }; + + let commandName; + switch (mappingName) { + case 'departments': + commandName = WRITE_COMMANDS.UPDATE_NETSUITE_DEPARTMENTS_MAPPING; + break; + case 'classes': + commandName = WRITE_COMMANDS.UPDATE_NETSUITE_CLASSES_MAPPING; + break; + case 'locations': + commandName = WRITE_COMMANDS.UPDATE_NETSUITE_LOCATIONS_MAPPING; + break; + case 'customers': + commandName = WRITE_COMMANDS.UPDATE_NETSUITE_CUSTOMERS_MAPPING; + break; + case 'jobs': + commandName = WRITE_COMMANDS.UPDATE_NETSUITE_JOBS_MAPPING; + break; + default: + return; + } + + API.write(commandName, params, onyxData); +} + +function updateNetSuiteSyncTaxConfiguration(policyID: string, isSyncTaxEnabled: boolean) { + const onyxData = updateNetSuiteSyncOptionsOnyxData(policyID, CONST.NETSUITE_CONFIG.SYNC_OPTIONS.SYNC_TAX, isSyncTaxEnabled, !isSyncTaxEnabled); + const params = { policyID, enabled: isSyncTaxEnabled, @@ -265,6 +412,21 @@ function updateNetSuiteSyncTaxConfiguration(policyID: string, isSyncTaxEnabled: API.write(WRITE_COMMANDS.UPDATE_NETSUITE_SYNC_TAX_CONFIGURATION, params, onyxData); } +function updateNetSuiteCrossSubsidiaryCustomersConfiguration(policyID: string, isCrossSubsidiaryCustomersEnabled: boolean) { + const onyxData = updateNetSuiteSyncOptionsOnyxData( + policyID, + CONST.NETSUITE_CONFIG.SYNC_OPTIONS.CROSS_SUBSIDIARY_CUSTOMERS, + isCrossSubsidiaryCustomersEnabled, + !isCrossSubsidiaryCustomersEnabled, + ); + + const params = { + policyID, + enabled: isCrossSubsidiaryCustomersEnabled, + }; + API.write(WRITE_COMMANDS.UPDATE_NETSUITE_CROSS_SUBSIDIARY_CUSTOMER_CONFIGURATION, params, onyxData); +} + function updateNetSuiteExporter(policyID: string, exporter: string, oldExporter: string) { const onyxData = updateNetSuiteOnyxData(policyID, CONST.NETSUITE_CONFIG.EXPORTER, exporter, oldExporter); @@ -431,6 +593,142 @@ function updateNetSuiteExportToNextOpenPeriod(policyID: string, value: boolean, API.write(WRITE_COMMANDS.UPDATE_NETSUITE_EXPORT_TO_NEXT_OPEN_PERIOD, parameters, onyxData); } +function updateNetSuiteAutoSync(policyID: string, value: boolean) { + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + connections: { + netsuite: { + config: { + autoSync: { + enabled: value, + }, + pendingFields: { + autoSync: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + errorFields: { + autoSync: null, + }, + }, + }, + }, + }, + }, + ]; + + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + connections: { + netsuite: { + config: { + autoSync: { + enabled: !value, + }, + pendingFields: { + autoSync: null, + }, + errorFields: { + autoSync: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'), + }, + }, + }, + }, + }, + }, + ]; + + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + connections: { + netsuite: { + config: { + autoSync: { + enabled: value, + }, + pendingFields: { + autoSync: null, + }, + errorFields: { + autoSync: null, + }, + }, + }, + }, + }, + }, + ]; + + const parameters = { + policyID, + enabled: value, + }; + API.write(WRITE_COMMANDS.UPDATE_NETSUITE_AUTO_SYNC, parameters, {optimisticData, failureData, successData}); +} + +function updateNetSuiteSyncReimbursedReports(policyID: string, value: boolean) { + const onyxData = updateNetSuiteSyncOptionsOnyxData(policyID, CONST.NETSUITE_CONFIG.SYNC_OPTIONS.SYNC_REIMBURSED_REPORTS, value, !value); + + const parameters = { + policyID, + enabled: value, + }; + API.write(WRITE_COMMANDS.UPDATE_NETSUITE_SYNC_REIMBURSED_REPORTS, parameters, onyxData); +} + +function updateNetSuiteSyncPeople(policyID: string, value: boolean) { + const onyxData = updateNetSuiteSyncOptionsOnyxData(policyID, CONST.NETSUITE_CONFIG.SYNC_OPTIONS.SYNC_PEOPLE, value, !value); + + const parameters = { + policyID, + enabled: value, + }; + API.write(WRITE_COMMANDS.UPDATE_NETSUITE_SYNC_PEOPLE, parameters, onyxData); +} + +function updateNetSuiteAutoCreateEntities(policyID: string, value: boolean) { + const onyxData = updateNetSuiteOnyxData(policyID, CONST.NETSUITE_CONFIG.AUTO_CREATE_ENTITIES, value, !value); + + const parameters = { + policyID, + enabled: value, + }; + API.write(WRITE_COMMANDS.UPDATE_NETSUITE_AUTO_CREATE_ENTITIES, parameters, onyxData); +} + +function updateNetSuiteEnableNewCategories(policyID: string, value: boolean) { + const onyxData = updateNetSuiteSyncOptionsOnyxData(policyID, CONST.NETSUITE_CONFIG.SYNC_OPTIONS.ENABLE_NEW_CATEGORIES, value, !value); + + const parameters = { + policyID, + enabled: value, + }; + API.write(WRITE_COMMANDS.UPDATE_NETSUITE_ENABLE_NEW_CATEGORIES, parameters, onyxData); +} + +function updateNetSuiteCustomFormIDOptionsEnabled(policyID: string, value: boolean) { + const data = { + enabled: value, + }; + const oldData = { + enabled: !value, + }; + const onyxData = updateNetSuiteOnyxData(policyID, CONST.NETSUITE_CONFIG.CUSTOM_FORM_ID_OPTIONS, data, oldData); + + const parameters = { + policyID, + enabled: value, + }; + API.write(WRITE_COMMANDS.UPDATE_NETSUITE_CUSTOM_FORM_ID_OPTIONS_ENABLED, parameters, onyxData); +} + export { updateNetSuiteSubsidiary, updateNetSuiteSyncTaxConfiguration, @@ -449,4 +747,13 @@ export { updateNetSuiteProvincialTaxPostingAccount, updateNetSuiteAllowForeignCurrency, updateNetSuiteExportToNextOpenPeriod, + updateNetSuiteImportMapping, + updateNetSuiteCrossSubsidiaryCustomersConfiguration, + updateNetSuiteAutoSync, + updateNetSuiteSyncReimbursedReports, + updateNetSuiteSyncPeople, + updateNetSuiteAutoCreateEntities, + updateNetSuiteEnableNewCategories, + updateNetSuiteCustomFormIDOptionsEnabled, + connectPolicyToNetSuite, }; diff --git a/src/libs/fileDownload/index.ios.ts b/src/libs/fileDownload/index.ios.ts index 0e6701dbda3a..b1617bb440d0 100644 --- a/src/libs/fileDownload/index.ios.ts +++ b/src/libs/fileDownload/index.ios.ts @@ -44,7 +44,7 @@ function downloadVideo(fileUrl: string, fileName: string): Promise { - documentPathUri = attachment.data; + documentPathUri = attachment.data as string | null; if (!documentPathUri) { throw new Error('Error downloading video'); } diff --git a/src/libs/migrateOnyx.ts b/src/libs/migrateOnyx.ts index 3556746dca2f..332e4a020cab 100644 --- a/src/libs/migrateOnyx.ts +++ b/src/libs/migrateOnyx.ts @@ -1,5 +1,4 @@ import Log from './Log'; -import CheckForPreviousReportActionID from './migrations/CheckForPreviousReportActionID'; import KeyReportActionsDraftByReportActionID from './migrations/KeyReportActionsDraftByReportActionID'; import NVPMigration from './migrations/NVPMigration'; import Participants from './migrations/Participants'; @@ -17,7 +16,6 @@ export default function () { // Add all migrations to an array so they are executed in order const migrationPromises = [ RenameCardIsVirtual, - CheckForPreviousReportActionID, RenameReceiptFilename, KeyReportActionsDraftByReportActionID, TransactionBackupsToCollection, diff --git a/src/libs/migrations/CheckForPreviousReportActionID.ts b/src/libs/migrations/CheckForPreviousReportActionID.ts deleted file mode 100644 index 83658ff961c0..000000000000 --- a/src/libs/migrations/CheckForPreviousReportActionID.ts +++ /dev/null @@ -1,66 +0,0 @@ -import type {OnyxCollection} from 'react-native-onyx'; -import Onyx from 'react-native-onyx'; -import Log from '@libs/Log'; -import ONYXKEYS from '@src/ONYXKEYS'; -import type * as OnyxTypes from '@src/types/onyx'; -import type {ReportActionsCollectionDataSet} from '@src/types/onyx/ReportAction'; - -function getReportActionsFromOnyx(): Promise> { - return new Promise((resolve) => { - const connectionID = Onyx.connect({ - key: ONYXKEYS.COLLECTION.REPORT_ACTIONS, - waitForCollectionCallback: true, - callback: (allReportActions) => { - Onyx.disconnect(connectionID); - return resolve(allReportActions); - }, - }); - }); -} - -/** - * This migration checks for the 'previousReportActionID' key in the first valid reportAction of a report in Onyx. - * If the key is not found then all reportActions for all reports are removed from Onyx. - */ -export default function (): Promise { - return getReportActionsFromOnyx().then((allReportActions) => { - if (Object.keys(allReportActions ?? {}).length === 0) { - Log.info(`[Migrate Onyx] Skipped migration CheckForPreviousReportActionID because there were no reportActions`); - return; - } - - let firstValidValue: OnyxTypes.ReportAction | undefined; - - Object.values(allReportActions ?? {}).some((reportActions) => - Object.values(reportActions ?? {}).some((reportActionData) => { - if ('reportActionID' in reportActionData) { - firstValidValue = reportActionData; - return true; - } - - return false; - }), - ); - - if (!firstValidValue) { - Log.info(`[Migrate Onyx] Skipped migration CheckForPreviousReportActionID because there were no valid reportActions`); - return; - } - - if (firstValidValue.previousReportActionID) { - Log.info(`[Migrate Onyx] CheckForPreviousReportActionID Migration: previousReportActionID found. Migration complete`); - return; - } - - // If previousReportActionID not found: - Log.info(`[Migrate Onyx] CheckForPreviousReportActionID Migration: removing all reportActions because previousReportActionID not found in the first valid reportAction`); - - const onyxData: OnyxCollection = {}; - - Object.keys(allReportActions ?? {}).forEach((onyxKey) => { - onyxData[onyxKey] = {}; - }); - - return Onyx.multiSet(onyxData as ReportActionsCollectionDataSet); - }); -} diff --git a/src/pages/settings/Profile/PersonalDetails/AddressPage.tsx b/src/pages/AddressPage.tsx similarity index 57% rename from src/pages/settings/Profile/PersonalDetails/AddressPage.tsx rename to src/pages/AddressPage.tsx index 91a8b94537ab..852c57595b70 100644 --- a/src/pages/settings/Profile/PersonalDetails/AddressPage.tsx +++ b/src/pages/AddressPage.tsx @@ -1,60 +1,35 @@ -import type {StackScreenProps} from '@react-navigation/stack'; -import React, {useCallback, useEffect, useMemo, useState} from 'react'; +import React, {useCallback, useEffect, useState} from 'react'; import type {OnyxEntry} from 'react-native-onyx'; -import {withOnyx} from 'react-native-onyx'; import AddressForm from '@components/AddressForm'; import FullscreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; -import useGeographicalStateFromRoute from '@hooks/useGeographicalStateFromRoute'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import Navigation from '@libs/Navigation/Navigation'; -import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; -import * as PersonalDetails from '@userActions/PersonalDetails'; import type {FormOnyxValues} from '@src/components/Form/types'; -import CONST from '@src/CONST'; import type {Country} from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type SCREENS from '@src/SCREENS'; -import type {PrivatePersonalDetails} from '@src/types/onyx'; import type {Address} from '@src/types/onyx/PrivatePersonalDetails'; -type AddressPageOnyxProps = { +type AddressPageProps = { /** User's private personal details */ - privatePersonalDetails: OnyxEntry; + address?: Address; /** Whether app is loading */ isLoadingApp: OnyxEntry; + /** Function to call when address form is submitted */ + updateAddress: (values: FormOnyxValues) => void; + /** Title of address page */ + title: string; }; -type AddressPageProps = StackScreenProps & AddressPageOnyxProps; - -/** - * Submit form to update user's first and last legal name - * @param values - form input values - */ -function updateAddress(values: FormOnyxValues) { - PersonalDetails.updateAddress( - values.addressLine1?.trim() ?? '', - values.addressLine2?.trim() ?? '', - values.city.trim(), - values.state.trim(), - values?.zipPostCode?.trim().toUpperCase() ?? '', - values.country, - ); -} - -function AddressPage({privatePersonalDetails, route, isLoadingApp = true}: AddressPageProps) { +function AddressPage({title, address, updateAddress, isLoadingApp = true}: AddressPageProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); - const address = useMemo(() => privatePersonalDetails?.address, [privatePersonalDetails]); - const countryFromUrlTemp = route?.params?.country; // Check if country is valid - const countryFromUrl = CONST.ALL_COUNTRIES[countryFromUrlTemp as keyof typeof CONST.ALL_COUNTRIES] ? countryFromUrlTemp : ''; - const stateFromUrl = useGeographicalStateFromRoute(); + const {street, street2} = address ?? {}; const [currentCountry, setCurrentCountry] = useState(address?.country); - const [street1, street2] = (address?.street ?? '').split('\n'); const [state, setState] = useState(address?.state); const [city, setCity] = useState(address?.city); const [zipcode, setZipcode] = useState(address?.zip); @@ -67,7 +42,8 @@ function AddressPage({privatePersonalDetails, route, isLoadingApp = true}: Addre setCurrentCountry(address.country); setCity(address.city); setZipcode(address.zip); - }, [address]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [address?.state, address?.country, address?.city, address?.zip]); const handleAddressChange = useCallback((value: unknown, key: unknown) => { const addressPart = value as string; @@ -97,27 +73,13 @@ function AddressPage({privatePersonalDetails, route, isLoadingApp = true}: Addre setZipcode(addressPart); }, []); - useEffect(() => { - if (!countryFromUrl) { - return; - } - handleAddressChange(countryFromUrl, 'country'); - }, [countryFromUrl, handleAddressChange]); - - useEffect(() => { - if (!stateFromUrl) { - return; - } - handleAddressChange(stateFromUrl, 'state'); - }, [handleAddressChange, stateFromUrl]); - return ( Navigation.goBack()} /> @@ -132,7 +94,7 @@ function AddressPage({privatePersonalDetails, route, isLoadingApp = true}: Addre country={currentCountry} onAddressChanged={handleAddressChange} state={state} - street1={street1} + street1={street} street2={street2} zip={zipcode} /> @@ -143,11 +105,4 @@ function AddressPage({privatePersonalDetails, route, isLoadingApp = true}: Addre AddressPage.displayName = 'AddressPage'; -export default withOnyx({ - privatePersonalDetails: { - key: ONYXKEYS.PRIVATE_PERSONAL_DETAILS, - }, - isLoadingApp: { - key: ONYXKEYS.IS_LOADING_APP, - }, -})(AddressPage); +export default AddressPage; diff --git a/src/pages/EnablePayments/EnablePayments.tsx b/src/pages/EnablePayments/EnablePayments.tsx index b8501551204a..742202e43bb3 100644 --- a/src/pages/EnablePayments/EnablePayments.tsx +++ b/src/pages/EnablePayments/EnablePayments.tsx @@ -2,6 +2,7 @@ import React, {useEffect} from 'react'; import {useOnyx} from 'react-native-onyx'; import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import ScreenWrapper from '@components/ScreenWrapper'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import Navigation from '@libs/Navigation/Navigation'; @@ -38,13 +39,17 @@ function EnablePaymentsPage() { if (userWallet?.errorCode === CONST.WALLET.ERROR.KYC) { return ( - <> + Navigation.goBack(ROUTES.SETTINGS_WALLET)} /> - + ); } diff --git a/src/pages/ProfilePage.tsx b/src/pages/ProfilePage.tsx index f088de064cc7..240f3307d158 100755 --- a/src/pages/ProfilePage.tsx +++ b/src/pages/ProfilePage.tsx @@ -30,6 +30,7 @@ import * as ReportUtils from '@libs/ReportUtils'; import * as UserUtils from '@libs/UserUtils'; import * as ValidationUtils from '@libs/ValidationUtils'; import type {ProfileNavigatorParamList} from '@navigation/types'; +import * as LinkActions from '@userActions/Link'; import * as PersonalDetailsActions from '@userActions/PersonalDetails'; import * as ReportActions from '@userActions/Report'; import * as SessionActions from '@userActions/Session'; @@ -79,6 +80,9 @@ function ProfilePage({route}: ProfilePageProps) { const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST); const [personalDetailsMetadata] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_METADATA); const [session] = useOnyx(ONYXKEYS.SESSION); + const [guideCalendarLink] = useOnyx(ONYXKEYS.ACCOUNT, { + selector: (account) => account?.guideCalendarLink, + }); const reportKey = useMemo(() => { const accountID = Number(route.params?.accountID ?? -1); @@ -175,6 +179,8 @@ function ProfilePage({route}: ProfilePageProps) { return result; }, [accountID, isCurrentUser, loginParams, report]); + const isConcierge = ReportUtils.isConciergeChatReport(report); + return ( @@ -276,6 +282,16 @@ function ProfilePage({route}: ProfilePageProps) { brickRoadIndicator={ReportActions.hasErrorInPrivateNotes(report) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} /> )} + {isConcierge && guideCalendarLink && ( + { + LinkActions.openExternalLink(guideCalendarLink); + })} + /> + )} {!hasAvatar && isLoading && } diff --git a/src/pages/ReportDetailsPage.tsx b/src/pages/ReportDetailsPage.tsx index b04e56f288e9..58a0fe1a80b8 100644 --- a/src/pages/ReportDetailsPage.tsx +++ b/src/pages/ReportDetailsPage.tsx @@ -1,4 +1,5 @@ import type {StackScreenProps} from '@react-navigation/stack'; +import {Str} from 'expensify-common'; import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; @@ -26,6 +27,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import Navigation from '@libs/Navigation/Navigation'; import type {ReportDetailsNavigatorParamList} from '@libs/Navigation/types'; import * as OptionsListUtils from '@libs/OptionsListUtils'; +import PaginationUtils from '@libs/PaginationUtils'; import * as PolicyUtils from '@libs/PolicyUtils'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; import * as ReportUtils from '@libs/ReportUtils'; @@ -83,17 +85,18 @@ function ReportDetailsPage({policies, report, session, personalDetails}: ReportD // The app would crash due to subscribing to the entire report collection if parentReportID is an empty string. So we should have a fallback ID here. // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const [parentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${report.parentReportID || '-1'}`); - const [sortedAllReportActions = []] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID ?? '-1'}`, { + const [sortedAllReportActions = []] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID || '-1'}`, { canEvict: false, selector: (allReportActions: OnyxEntry) => ReportActionsUtils.getSortedReportActionsForDisplay(allReportActions, true), }); + const [reportActionPages = []] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_PAGES}${report.reportID || '-1'}`); const reportActions = useMemo(() => { if (!sortedAllReportActions.length) { return []; } - return ReportActionsUtils.getContinuousReportActionChain(sortedAllReportActions); - }, [sortedAllReportActions]); + return PaginationUtils.getContinuousChain(sortedAllReportActions, reportActionPages, (reportAction) => reportAction.reportActionID); + }, [sortedAllReportActions, reportActionPages]); const transactionThreadReportID = useMemo( () => ReportActionsUtils.getOneTransactionThreadReportID(report.reportID, reportActions ?? [], isOffline), @@ -537,6 +540,47 @@ function ReportDetailsPage({policies, report, session, personalDetails}: ReportD ); + const titleField = useMemo((): OnyxTypes.PolicyReportField | undefined => { + const fields = ReportUtils.getAvailableReportFields(report, Object.values(policy?.fieldList ?? {})); + return fields.find((reportField) => ReportUtils.isReportFieldOfTypeTitle(reportField)); + }, [report, policy?.fieldList]); + const fieldKey = ReportUtils.getReportFieldKey(titleField?.fieldID ?? '-1'); + const isFieldDisabled = ReportUtils.isReportFieldDisabled(report, titleField, policy); + + const shouldShowTitleField = caseID !== CASES.MONEY_REQUEST && !isFieldDisabled && ReportUtils.isAdminOwnerApproverOrReportOwner(report, policy); + + const nameSectionFurtherDetailsContent = ( + + ); + + const nameSectionTitleField = titleField && ( + Report.clearReportFieldErrors(report.reportID, titleField)} + > + + Navigation.navigate(ROUTES.EDIT_REPORT_FIELD_REQUEST.getRoute(report.reportID, report.policyID ?? '-1', titleField.fieldID ?? '-1'))} + furtherDetailsComponent={nameSectionFurtherDetailsContent} + /> + + + ); + const navigateBackToAfterDelete = useRef(); const deleteTransaction = useCallback(() => { @@ -565,9 +609,11 @@ function ReportDetailsPage({policies, report, session, personalDetails}: ReportD {renderedAvatar} - {isExpenseReport && nameSectionExpenseIOU} + {isExpenseReport && !shouldShowTitleField && nameSectionExpenseIOU} + {isExpenseReport && shouldShowTitleField && nameSectionTitleField} + {!isExpenseReport && nameSectionGroupWorkspace} {shouldShowReportDescription && ( diff --git a/src/pages/TransactionDuplicate/DuplicateTransactionItem.tsx b/src/pages/TransactionDuplicate/DuplicateTransactionItem.tsx index 4881765ff76f..398ea8ae336b 100644 --- a/src/pages/TransactionDuplicate/DuplicateTransactionItem.tsx +++ b/src/pages/TransactionDuplicate/DuplicateTransactionItem.tsx @@ -1,6 +1,8 @@ import React from 'react'; +import {View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import {useOnyx} from 'react-native-onyx'; +import useThemeStyles from '@hooks/useThemeStyles'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; import ReportActionItem from '@pages/home/report/ReportActionItem'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -12,6 +14,7 @@ type DuplicateTransactionItemProps = { }; function DuplicateTransactionItem(props: DuplicateTransactionItemProps) { + const styles = useThemeStyles(); const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${props.transaction?.reportID}`); const [reportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report?.reportID}`); @@ -26,18 +29,20 @@ function DuplicateTransactionItem(props: DuplicateTransactionItemProps) { } return ( - + + + ); } diff --git a/src/pages/TransactionDuplicate/DuplicateTransactionsList.tsx b/src/pages/TransactionDuplicate/DuplicateTransactionsList.tsx index 8dd610bbd0be..00b80fecf824 100644 --- a/src/pages/TransactionDuplicate/DuplicateTransactionsList.tsx +++ b/src/pages/TransactionDuplicate/DuplicateTransactionsList.tsx @@ -2,6 +2,7 @@ import React from 'react'; import type {FlatListProps, ScrollViewProps} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import FlatList from '@components/FlatList'; +import useThemeStyles from '@hooks/useThemeStyles'; import type {Transaction} from '@src/types/onyx'; import DuplicateTransactionItem from './DuplicateTransactionItem'; @@ -23,12 +24,15 @@ const maintainVisibleContentPosition: ScrollViewProps['maintainVisibleContentPos }; function DuplicateTransactionsList({transactions}: DuplicateTransactionsListProps) { + const styles = useThemeStyles(); + return ( ); } diff --git a/src/pages/TransactionDuplicate/ReviewBillable.tsx b/src/pages/TransactionDuplicate/ReviewBillable.tsx new file mode 100644 index 000000000000..1f9be45f2cf0 --- /dev/null +++ b/src/pages/TransactionDuplicate/ReviewBillable.tsx @@ -0,0 +1,54 @@ +import type {RouteProp} from '@react-navigation/native'; +import {useRoute} from '@react-navigation/native'; +import React, {useMemo} from 'react'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import ScreenWrapper from '@components/ScreenWrapper'; +import useLocalize from '@hooks/useLocalize'; +import useReviewDuplicatesNavigation from '@hooks/useReviewDuplicatesNavigation'; +import {setReviewDuplicatesKey} from '@libs/actions/Transaction'; +import type {TransactionDuplicateNavigatorParamList} from '@libs/Navigation/types'; +import * as TransactionUtils from '@libs/TransactionUtils'; +import type SCREENS from '@src/SCREENS'; +import type {FieldItemType} from './ReviewFields'; +import ReviewFields from './ReviewFields'; + +function ReviewBillable() { + const route = useRoute>(); + const {translate} = useLocalize(); + const transactionID = TransactionUtils.getTransactionID(route.params.threadReportID ?? ''); + const compareResult = TransactionUtils.compareDuplicateTransactionFields(transactionID); + const stepNames = Object.keys(compareResult.change ?? {}).map((key, index) => (index + 1).toString()); + const {currentScreenIndex, navigateToNextScreen} = useReviewDuplicatesNavigation(Object.keys(compareResult.change ?? {}), 'billable', route.params.threadReportID ?? ''); + const options = useMemo( + () => + compareResult.change.billable?.map((billable) => ({ + text: billable ? translate('common.yes') : translate('common.no'), + value: billable, + })), + [compareResult.change.billable, translate], + ); + + const onSelectRow = (data: FieldItemType) => { + if (data.value !== undefined) { + setReviewDuplicatesKey({billable: data.value as boolean}); + } + navigateToNextScreen(); + }; + + return ( + + + + + ); +} + +ReviewBillable.displayName = 'ReviewBillable'; + +export default ReviewBillable; diff --git a/src/pages/TransactionDuplicate/ReviewCategory.tsx b/src/pages/TransactionDuplicate/ReviewCategory.tsx new file mode 100644 index 000000000000..7d55de2e6a7c --- /dev/null +++ b/src/pages/TransactionDuplicate/ReviewCategory.tsx @@ -0,0 +1,58 @@ +import type {RouteProp} from '@react-navigation/native'; +import {useRoute} from '@react-navigation/native'; +import React, {useMemo} from 'react'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import ScreenWrapper from '@components/ScreenWrapper'; +import useLocalize from '@hooks/useLocalize'; +import useReviewDuplicatesNavigation from '@hooks/useReviewDuplicatesNavigation'; +import {setReviewDuplicatesKey} from '@libs/actions/Transaction'; +import type {TransactionDuplicateNavigatorParamList} from '@libs/Navigation/types'; +import * as TransactionUtils from '@libs/TransactionUtils'; +import type SCREENS from '@src/SCREENS'; +import type {FieldItemType} from './ReviewFields'; +import ReviewFields from './ReviewFields'; + +function ReviewCategory() { + const route = useRoute>(); + const {translate} = useLocalize(); + const transactionID = TransactionUtils.getTransactionID(route.params.threadReportID ?? ''); + const compareResult = TransactionUtils.compareDuplicateTransactionFields(transactionID); + const stepNames = Object.keys(compareResult.change ?? {}).map((key, index) => (index + 1).toString()); + const {currentScreenIndex, navigateToNextScreen} = useReviewDuplicatesNavigation(Object.keys(compareResult.change ?? {}), 'category', route.params.threadReportID ?? ''); + const options = useMemo( + () => + compareResult.change.category?.map((category) => + !category + ? {text: translate('violations.none'), value: undefined} + : { + text: category, + value: category, + }, + ), + [compareResult.change.category, translate], + ); + + const onSelectRow = (data: FieldItemType) => { + if (data.value !== undefined) { + setReviewDuplicatesKey({category: data.value as string}); + } + navigateToNextScreen(); + }; + + return ( + + + + + ); +} + +ReviewCategory.displayName = 'ReviewCategory'; + +export default ReviewCategory; diff --git a/src/pages/TransactionDuplicate/ReviewDescription.tsx b/src/pages/TransactionDuplicate/ReviewDescription.tsx new file mode 100644 index 000000000000..787957b0e7e2 --- /dev/null +++ b/src/pages/TransactionDuplicate/ReviewDescription.tsx @@ -0,0 +1,57 @@ +import type {RouteProp} from '@react-navigation/native'; +import {useRoute} from '@react-navigation/native'; +import React, {useMemo} from 'react'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import ScreenWrapper from '@components/ScreenWrapper'; +import useLocalize from '@hooks/useLocalize'; +import useReviewDuplicatesNavigation from '@hooks/useReviewDuplicatesNavigation'; +import {setReviewDuplicatesKey} from '@libs/actions/Transaction'; +import type {TransactionDuplicateNavigatorParamList} from '@libs/Navigation/types'; +import * as TransactionUtils from '@libs/TransactionUtils'; +import type SCREENS from '@src/SCREENS'; +import type {FieldItemType} from './ReviewFields'; +import ReviewFields from './ReviewFields'; + +function ReviewDescription() { + const route = useRoute>(); + const {translate} = useLocalize(); + const transactionID = TransactionUtils.getTransactionID(route.params.threadReportID ?? ''); + const compareResult = TransactionUtils.compareDuplicateTransactionFields(transactionID); + const stepNames = Object.keys(compareResult.change ?? {}).map((key, index) => (index + 1).toString()); + const {currentScreenIndex, navigateToNextScreen} = useReviewDuplicatesNavigation(Object.keys(compareResult.change ?? {}), 'description', route.params.threadReportID ?? ''); + const options = useMemo( + () => + compareResult.change.description?.map((description) => + !description?.comment + ? {text: translate('violations.none'), value: ''} + : { + text: description.comment, + value: description.comment, + }, + ), + [compareResult.change.description, translate], + ); + const onSelectRow = (data: FieldItemType) => { + if (data.value !== undefined) { + setReviewDuplicatesKey({description: data.value as string}); + } + navigateToNextScreen(); + }; + + return ( + + + + + ); +} + +ReviewDescription.displayName = 'ReviewDescription'; + +export default ReviewDescription; diff --git a/src/pages/TransactionDuplicate/ReviewFields.tsx b/src/pages/TransactionDuplicate/ReviewFields.tsx new file mode 100644 index 000000000000..3c513c55e817 --- /dev/null +++ b/src/pages/TransactionDuplicate/ReviewFields.tsx @@ -0,0 +1,91 @@ +import React, {useMemo} from 'react'; +import {View} from 'react-native'; +import InteractiveStepSubHeader from '@components/InteractiveStepSubHeader'; +import SelectionList from '@components/SelectionList'; +import RadioListItem from '@components/SelectionList/RadioListItem'; +import Text from '@components/Text'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import variables from '@styles/variables'; +import CONST from '@src/CONST'; + +type FieldItemType = { + text: string; + value: string | boolean; + keyForList: string; +}; + +type ReviewFieldsProps = { + /* Step Names which are displayed in stepper */ + stepNames: string[]; + + /* Label which is displyed to describe current step */ + label: string; + + /* Values to choose from */ + options: Array<{text: string; value: string | boolean | undefined}> | undefined; + + /* Current index */ + index: number; + + /* Callback to what should happen after selecting row */ + onSelectRow: (item: FieldItemType) => void; +}; + +function ReviewFields({stepNames, label, options, index, onSelectRow}: ReviewFieldsProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + + let falsyCount = 0; + const filteredOptions = options?.filter((name) => { + if (name.text !== translate('violations.none')) { + return true; + } + falsyCount++; + return falsyCount <= 1; + }); + + const sections = useMemo( + () => + filteredOptions?.map((option) => ({ + text: option.text, + keyForList: option.text, + value: option.value ?? '', + })), + [filteredOptions], + ); + + return ( + + {stepNames.length > 1 && ( + + + + )} + + + {label} + + + + ); +} + +ReviewFields.displayName = 'ReviewFields'; + +export default ReviewFields; +export type {FieldItemType}; diff --git a/src/pages/TransactionDuplicate/ReviewMerchant.tsx b/src/pages/TransactionDuplicate/ReviewMerchant.tsx new file mode 100644 index 000000000000..b4a38ac5c527 --- /dev/null +++ b/src/pages/TransactionDuplicate/ReviewMerchant.tsx @@ -0,0 +1,58 @@ +import type {RouteProp} from '@react-navigation/native'; +import {useRoute} from '@react-navigation/native'; +import React, {useMemo} from 'react'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import ScreenWrapper from '@components/ScreenWrapper'; +import useLocalize from '@hooks/useLocalize'; +import useReviewDuplicatesNavigation from '@hooks/useReviewDuplicatesNavigation'; +import {setReviewDuplicatesKey} from '@libs/actions/Transaction'; +import type {TransactionDuplicateNavigatorParamList} from '@libs/Navigation/types'; +import * as TransactionUtils from '@libs/TransactionUtils'; +import type SCREENS from '@src/SCREENS'; +import type {FieldItemType} from './ReviewFields'; +import ReviewFields from './ReviewFields'; + +function ReviewMerchant() { + const route = useRoute>(); + const {translate} = useLocalize(); + const transactionID = TransactionUtils.getTransactionID(route.params.threadReportID ?? ''); + const compareResult = TransactionUtils.compareDuplicateTransactionFields(transactionID); + const stepNames = Object.keys(compareResult.change ?? {}).map((key, index) => (index + 1).toString()); + const {currentScreenIndex, navigateToNextScreen} = useReviewDuplicatesNavigation(Object.keys(compareResult.change ?? {}), 'merchant', route.params.threadReportID ?? ''); + const options = useMemo( + () => + compareResult.change.merchant?.map((merchant) => + !merchant + ? {text: translate('violations.none'), value: undefined} + : { + text: merchant, + value: merchant, + }, + ), + [compareResult.change.merchant, translate], + ); + + const onSelectRow = (data: FieldItemType) => { + if (data.value !== undefined) { + setReviewDuplicatesKey({merchant: data.value as string}); + } + navigateToNextScreen(); + }; + + return ( + + + + + ); +} + +ReviewMerchant.displayName = 'ReviewMerchant'; + +export default ReviewMerchant; diff --git a/src/pages/TransactionDuplicate/ReviewReimbursable.tsx b/src/pages/TransactionDuplicate/ReviewReimbursable.tsx new file mode 100644 index 000000000000..1ff187213a0c --- /dev/null +++ b/src/pages/TransactionDuplicate/ReviewReimbursable.tsx @@ -0,0 +1,54 @@ +import type {RouteProp} from '@react-navigation/native'; +import {useRoute} from '@react-navigation/native'; +import React, {useMemo} from 'react'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import ScreenWrapper from '@components/ScreenWrapper'; +import useLocalize from '@hooks/useLocalize'; +import useReviewDuplicatesNavigation from '@hooks/useReviewDuplicatesNavigation'; +import {setReviewDuplicatesKey} from '@libs/actions/Transaction'; +import type {TransactionDuplicateNavigatorParamList} from '@libs/Navigation/types'; +import * as TransactionUtils from '@libs/TransactionUtils'; +import type SCREENS from '@src/SCREENS'; +import type {FieldItemType} from './ReviewFields'; +import ReviewFields from './ReviewFields'; + +function ReviewReimbursable() { + const route = useRoute>(); + const {translate} = useLocalize(); + const transactionID = TransactionUtils.getTransactionID(route.params.threadReportID ?? ''); + const compareResult = TransactionUtils.compareDuplicateTransactionFields(transactionID); + const stepNames = Object.keys(compareResult.change ?? {}).map((key, index) => (index + 1).toString()); + const {currentScreenIndex, navigateToNextScreen} = useReviewDuplicatesNavigation(Object.keys(compareResult.change ?? {}), 'reimbursable', route.params.threadReportID ?? ''); + const options = useMemo( + () => + compareResult.change.reimbursable?.map((reimbursable) => ({ + text: reimbursable ? translate('common.yes') : translate('common.no'), + value: reimbursable, + })), + [compareResult.change.reimbursable, translate], + ); + + const onSelectRow = (data: FieldItemType) => { + if (data.value !== undefined) { + setReviewDuplicatesKey({reimbursable: data.value as boolean}); + } + navigateToNextScreen(); + }; + + return ( + + + + + ); +} + +ReviewReimbursable.displayName = 'ReviewReimbursable'; + +export default ReviewReimbursable; diff --git a/src/pages/TransactionDuplicate/ReviewTag.tsx b/src/pages/TransactionDuplicate/ReviewTag.tsx new file mode 100644 index 000000000000..192434678a78 --- /dev/null +++ b/src/pages/TransactionDuplicate/ReviewTag.tsx @@ -0,0 +1,58 @@ +import type {RouteProp} from '@react-navigation/native'; +import {useRoute} from '@react-navigation/native'; +import React, {useMemo} from 'react'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import ScreenWrapper from '@components/ScreenWrapper'; +import useLocalize from '@hooks/useLocalize'; +import useReviewDuplicatesNavigation from '@hooks/useReviewDuplicatesNavigation'; +import {setReviewDuplicatesKey} from '@libs/actions/Transaction'; +import type {TransactionDuplicateNavigatorParamList} from '@libs/Navigation/types'; +import * as TransactionUtils from '@libs/TransactionUtils'; +import type SCREENS from '@src/SCREENS'; +import type {FieldItemType} from './ReviewFields'; +import ReviewFields from './ReviewFields'; + +function ReviewTag() { + const route = useRoute>(); + const {translate} = useLocalize(); + const transactionID = TransactionUtils.getTransactionID(route.params.threadReportID ?? ''); + + const compareResult = TransactionUtils.compareDuplicateTransactionFields(transactionID); + const stepNames = Object.keys(compareResult.change ?? {}).map((key, index) => (index + 1).toString()); + const {currentScreenIndex, navigateToNextScreen} = useReviewDuplicatesNavigation(Object.keys(compareResult.change ?? {}), 'tag', route.params.threadReportID ?? ''); + const options = useMemo( + () => + compareResult.change.tag?.map((tag) => + !tag + ? {text: translate('violations.none'), value: undefined} + : { + text: tag, + value: tag, + }, + ), + [compareResult.change.tag, translate], + ); + const onSelectRow = (data: FieldItemType) => { + if (data.value !== undefined) { + setReviewDuplicatesKey({tag: data.value as string}); + } + navigateToNextScreen(); + }; + + return ( + + + + + ); +} + +ReviewTag.displayName = 'ReviewTag'; + +export default ReviewTag; diff --git a/src/pages/TransactionDuplicate/ReviewTaxCode.tsx b/src/pages/TransactionDuplicate/ReviewTaxCode.tsx new file mode 100644 index 000000000000..77ca169969fc --- /dev/null +++ b/src/pages/TransactionDuplicate/ReviewTaxCode.tsx @@ -0,0 +1,64 @@ +import type {RouteProp} from '@react-navigation/native'; +import {useRoute} from '@react-navigation/native'; +import React, {useMemo} from 'react'; +import {useOnyx} from 'react-native-onyx'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import ScreenWrapper from '@components/ScreenWrapper'; +import useLocalize from '@hooks/useLocalize'; +import useReviewDuplicatesNavigation from '@hooks/useReviewDuplicatesNavigation'; +import {setReviewDuplicatesKey} from '@libs/actions/Transaction'; +import type {TransactionDuplicateNavigatorParamList} from '@libs/Navigation/types'; +import * as PolicyUtils from '@libs/PolicyUtils'; +import * as TransactionUtils from '@libs/TransactionUtils'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type SCREENS from '@src/SCREENS'; +import ReviewDescription from './ReviewDescription'; +import type {FieldItemType} from './ReviewFields'; +import ReviewFields from './ReviewFields'; + +function ReviewTaxRate() { + const route = useRoute>(); + const {translate} = useLocalize(); + const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${route.params.threadReportID}`); + const policy = PolicyUtils.getPolicy(report?.policyID ?? ''); + const transactionID = TransactionUtils.getTransactionID(route.params.threadReportID ?? ''); + const compareResult = TransactionUtils.compareDuplicateTransactionFields(transactionID); + const stepNames = Object.keys(compareResult.change ?? {}).map((key, index) => (index + 1).toString()); + const {currentScreenIndex, navigateToNextScreen} = useReviewDuplicatesNavigation(Object.keys(compareResult.change ?? {}), 'taxCode', route.params.threadReportID ?? ''); + const options = useMemo( + () => + compareResult.change.taxCode?.map((taxID) => + !taxID + ? {text: translate('violations.none'), value: undefined} + : { + text: PolicyUtils.getTaxByID(policy, taxID)?.name ?? '', + value: taxID, + }, + ), + [compareResult.change.taxCode, policy, translate], + ); + + const onSelectRow = (data: FieldItemType) => { + if (data.value !== undefined) { + setReviewDuplicatesKey({taxCode: data.value as string}); + } + navigateToNextScreen(); + }; + + return ( + + + + + ); +} + +ReviewTaxRate.displayName = 'ReviewTaxRate'; + +export default ReviewTaxRate; diff --git a/src/pages/home/HeaderView.tsx b/src/pages/home/HeaderView.tsx index 53d2347a2809..7077c1a325c5 100644 --- a/src/pages/home/HeaderView.tsx +++ b/src/pages/home/HeaderView.tsx @@ -1,4 +1,4 @@ -import React, {memo, useMemo} from 'react'; +import React, {memo} from 'react'; import {View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import {withOnyx} from 'react-native-onyx'; @@ -7,7 +7,6 @@ import Button from '@components/Button'; import CaretWrapper from '@components/CaretWrapper'; import ConfirmModal from '@components/ConfirmModal'; import DisplayNames from '@components/DisplayNames'; -import type {ThreeDotsMenuItem} from '@components/HeaderWithBackButton/types'; import Icon from '@components/Icon'; import * as Expensicons from '@components/Icon/Expensicons'; import MultipleAvatars from '@components/MultipleAvatars'; @@ -18,18 +17,14 @@ import ReportHeaderSkeletonView from '@components/ReportHeaderSkeletonView'; import SubscriptAvatar from '@components/SubscriptAvatar'; import TaskHeaderActionButton from '@components/TaskHeaderActionButton'; import Text from '@components/Text'; -import ThreeDotsMenu from '@components/ThreeDotsMenu'; import Tooltip from '@components/Tooltip'; import useLocalize from '@hooks/useLocalize'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; -import useWindowDimensions from '@hooks/useWindowDimensions'; -import * as HeaderUtils from '@libs/HeaderUtils'; import Navigation from '@libs/Navigation/Navigation'; import * as OptionsListUtils from '@libs/OptionsListUtils'; import * as ReportUtils from '@libs/ReportUtils'; import * as SubscriptionUtils from '@libs/SubscriptionUtils'; -import * as Link from '@userActions/Link'; import * as Report from '@userActions/Report'; import * as Session from '@userActions/Session'; import * as Task from '@userActions/Task'; @@ -40,12 +35,6 @@ import type * as OnyxTypes from '@src/types/onyx'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; type HeaderViewOnyxProps = { - /** URL to the assigned guide's appointment booking calendar */ - guideCalendarLink: OnyxEntry; - - /** Current user session */ - session: OnyxEntry; - /** Personal details of all the users */ personalDetails: OnyxEntry; @@ -73,20 +62,8 @@ type HeaderViewProps = HeaderViewOnyxProps & { shouldUseNarrowLayout?: boolean; }; -function HeaderView({ - report, - personalDetails, - parentReport, - parentReportAction, - policy, - session, - reportID, - guideCalendarLink, - onNavigationMenuButtonClicked, - shouldUseNarrowLayout = false, -}: HeaderViewProps) { +function HeaderView({report, personalDetails, parentReport, parentReportAction, policy, reportID, onNavigationMenuButtonClicked, shouldUseNarrowLayout = false}: HeaderViewProps) { const [isDeleteTaskConfirmModalVisible, setIsDeleteTaskConfirmModalVisible] = React.useState(false); - const {windowWidth} = useWindowDimensions(); const {translate} = useLocalize(); const theme = useTheme(); const styles = useThemeStyles(); @@ -108,9 +85,6 @@ function HeaderView({ const title = ReportUtils.getReportName(reportHeaderData, undefined, parentReportAction); const subtitle = ReportUtils.getChatRoomSubtitle(reportHeaderData); const parentNavigationSubtitleData = ReportUtils.getParentNavigationSubtitle(reportHeaderData); - const isConcierge = ReportUtils.isConciergeChatReport(report); - const isCanceledTaskReport = ReportUtils.isCanceledTaskReport(report, parentReportAction); - const isPolicyEmployee = useMemo(() => !isEmptyObject(policy), [policy]); const reportDescription = ReportUtils.getReportDescriptionText(report); const policyName = ReportUtils.getPolicyName(report, true); const policyDescription = ReportUtils.getPolicyDescriptionText(policy); @@ -128,48 +102,9 @@ function HeaderView({ return true; }; - // We hide the button when we are chatting with an automated Expensify account since it's not possible to contact - // these users via alternative means. It is possible to request a call with Concierge so we leave the option for them. - const threeDotMenuItems: ThreeDotsMenuItem[] = []; - if (isTaskReport && !isCanceledTaskReport) { - const canModifyTask = Task.canModifyTask(report, session?.accountID ?? -1); - - // Task is marked as completed - if (ReportUtils.isCompletedTaskReport(report) && canModifyTask) { - threeDotMenuItems.push({ - icon: Expensicons.Checkmark, - text: translate('task.markAsIncomplete'), - onSelected: Session.checkIfActionIsAllowed(() => Task.reopenTask(report)), - }); - } - - // Task is not closed - if (ReportUtils.canWriteInReport(report) && report.stateNum !== CONST.REPORT.STATE_NUM.APPROVED && !ReportUtils.isClosedReport(report) && canModifyTask) { - threeDotMenuItems.push({ - icon: Expensicons.Trashcan, - text: translate('common.delete'), - onSelected: Session.checkIfActionIsAllowed(() => setIsDeleteTaskConfirmModalVisible(true)), - }); - } - } - const join = Session.checkIfActionIsAllowed(() => Report.joinRoom(report)); const canJoin = ReportUtils.canJoinChat(report, parentReportAction, policy); - if (canJoin) { - threeDotMenuItems.push({ - icon: Expensicons.ChatBubbles, - text: translate('common.join'), - onSelected: join, - }); - } else if (ReportUtils.canLeaveChat(report, policy)) { - const isWorkspaceMemberLeavingWorkspaceRoom = !isChatThread && (report.visibility === CONST.REPORT.VISIBILITY.RESTRICTED || isPolicyExpenseChat) && isPolicyEmployee; - threeDotMenuItems.push({ - icon: Expensicons.ChatBubbles, - text: translate('common.leave'), - onSelected: Session.checkIfActionIsAllowed(() => Report.leaveRoom(reportID, isWorkspaceMemberLeavingWorkspaceRoom)), - }); - } const joinButton = (