diff --git a/.eslintrc.js b/.eslintrc.js index 22bb0158bc8e..27014cf9dd7b 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -109,7 +109,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 192537f08e3d..cb4c7f28e265 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 1009000306 - versionName "9.0.3-6" + versionCode 1009000405 + versionName "9.0.4-5" // 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/expensifyCard/cardIllustration.svg b/assets/images/expensifyCard/cardIllustration.svg new file mode 100644 index 000000000000..f8162bbd913f --- /dev/null +++ b/assets/images/expensifyCard/cardIllustration.svg @@ -0,0 +1,487 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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/bank-accounts-and-payments/Global-Reimbursements.md b/docs/articles/expensify-classic/bank-accounts-and-payments/Global-Reimbursements.md index 2ff74760b376..0fd47f1341fa 100644 --- a/docs/articles/expensify-classic/bank-accounts-and-payments/Global-Reimbursements.md +++ b/docs/articles/expensify-classic/bank-accounts-and-payments/Global-Reimbursements.md @@ -7,29 +7,9 @@ description: International Reimbursements If your company’s business bank account is in the US, Canada, the UK, Europe, or Australia, you now have the option to send direct reimbursements to nearly any country across the globe! The process to enable global reimbursements is dependent on the currency of your reimbursement bank account, so be sure to review the corresponding instructions below. -# How to request international reimbursements - -## The reimbursement account is in USD - -If your reimbursement bank account is in USD, the first step is connecting the bank account to Expensify. -The individual who plans on sending reimbursements internationally should head to **Settings > Account > Payments > Add Verified Bank Account**. From there, you will provide company details, input personal information, and upload a copy of your ID. - -Once the USD bank account is verified (or if you already had a USD business bank account connected), click the support icon in your Expensify account to inform your Setup Specialist, Account Manager, or Concierge that you’d like to enable international reimbursements. From there, Expensify will ask you to confirm the currencies of the reimbursement and employee bank accounts. - -Our team will assess your account, and if you meet the criteria, international reimbursements will be enabled. - -## The reimbursement account is in AUD, CAD, GBP, EUR - -To request international reimbursements, contact Expensify Support to make that request. - -You can do this by clicking on the support icon and informing your Setup Specialist, Account Manager, or Concierge that you’d like to set up global reimbursements on your account. -From there, Expensify will ask you to confirm both the currencies of the reimbursement and employee bank accounts. - -Our team will assess your account, and if you meet the criteria, international reimbursements will be enabled. - # How to verify the bank account for sending international payments -Once international payments are enabled on your Expensify account, the next step is verifying the bank account to send the reimbursements. +The steps for USD accounts and non-USD accounts differ slightly. ## The reimbursement account is in USD @@ -38,9 +18,9 @@ First, confirm the workspace settings are set up correctly by doing the followin 2. Under **Settings > Workspaces > Group > _[Workspace Name]_ > Reimbursements**, set the reimbursement method to direct 3. Under **Settings > Workspaces > Group > _[Workspace Name]_ > Reimbursements**, set the USD bank account to the default account -Once that’s all set, head to **Settings > Account > Payments**, and click **Enable Global Reimbursement** on the bank account (this button may not show for up to 60 minutes after the Expensify team confirms international reimbursements are available on your account). +Once that’s all set, head to **Settings > Account > Payments**, and click **Enable Global Reimbursement** on the bank account. -From there, you’ll fill out a form via DocuSign. Once the form is complete, it is automatically sent to our Compliance Team for review. Our Support Team will contact you with more details if additional information is required. +From there, you’ll fill out a form via DocuSign. Once the form is complete, it is automatically sent to our Compliance Team for review. If additional information is required, our Support Team will contact you with more details. ## The reimbursement account is in AUD, CAD, GBP, EUR @@ -53,7 +33,7 @@ Next, add the bank account to Expensify: 4. Enter the bank account details 5. Click **Save & Continue** -From there, you’ll fill out a form via DocuSign. Once the form is complete, it is automatically sent to our Compliance Team for review. Our Support Team will contact you with more details if additional information is required. +From there, you’ll fill out a form via DocuSign. Once the form is complete, it is automatically sent to our Compliance Team for review. If additional information is required, our Support Team will contact you with more details. # How to start reimbursing internationally 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/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 17eaae3cc3fc..dc9e22924622 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -19,7 +19,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 9.0.3 + 9.0.4 CFBundleSignature ???? CFBundleURLTypes @@ -40,7 +40,7 @@ CFBundleVersion - 9.0.3.6 + 9.0.4.5 FullStory OrgId diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index 618d394349ed..94945a38c2cd 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -15,10 +15,10 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 9.0.3 + 9.0.4 CFBundleSignature ???? CFBundleVersion - 9.0.3.6 + 9.0.4.5 diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index d5e50828e3c7..06208839de66 100644 --- a/ios/NotificationServiceExtension/Info.plist +++ b/ios/NotificationServiceExtension/Info.plist @@ -11,9 +11,9 @@ CFBundleName $(PRODUCT_NAME) CFBundleShortVersionString - 9.0.3 + 9.0.4 CFBundleVersion - 9.0.3.6 + 9.0.4.5 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 9f63be958d1a..e160f2b882c7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "9.0.3-6", + "version": "9.0.4-5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "9.0.3-6", + "version": "9.0.4-5", "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 c61316e22030..b6cf6376f3cc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "9.0.3-6", + "version": "9.0.4-5", "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 46782be36b62..4e7557e0b4ba 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -74,6 +74,9 @@ const onboardingChoices = { type OnboardingPurposeType = ValueOf; const CONST = { + DEFAULT_DB_NAME: 'OnyxDB', + DEFAULT_TABLE_NAME: 'keyvaluepairs', + DEFAULT_ONYX_DUMP_FILE_NAME: 'onyx-state.txt', DEFAULT_POLICY_ROOM_CHAT_TYPES: [chatTypes.POLICY_ADMINS, chatTypes.POLICY_ANNOUNCE, chatTypes.DOMAIN_ALL], // Note: Group and Self-DM excluded as these are not tied to a Workspace @@ -1369,10 +1372,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', + }, }, }, @@ -1388,6 +1416,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', @@ -1403,6 +1437,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) * @@ -1849,6 +1904,11 @@ const CONST = { MAKE_MEMBER: 'makeMember', MAKE_ADMIN: 'makeAdmin', }, + BULK_ACTION_TYPES: { + DELETE: 'delete', + DISABLE: 'disable', + ENABLE: 'enable', + }, MORE_FEATURES: { ARE_CATEGORIES_ENABLED: 'areCategoriesEnabled', ARE_TAGS_ENABLED: 'areTagsEnabled', @@ -1859,21 +1919,6 @@ const CONST = { ARE_EXPENSIFY_CARDS_ENABLED: 'areExpensifyCardsEnabled', ARE_TAXES_ENABLED: 'tax', }, - CATEGORIES_BULK_ACTION_TYPES: { - DELETE: 'delete', - DISABLE: 'disable', - ENABLE: 'enable', - }, - TAGS_BULK_ACTION_TYPES: { - DELETE: 'delete', - DISABLE: 'disable', - ENABLE: 'enable', - }, - DISTANCE_RATES_BULK_ACTION_TYPES: { - DELETE: 'delete', - DISABLE: 'disable', - ENABLE: 'enable', - }, DEFAULT_CATEGORIES: [ 'Advertising', 'Benefits', @@ -1904,11 +1949,6 @@ const CONST = { DUPLICATE_SUBSCRIPTION: 'duplicateSubscription', FAILED_TO_CLEAR_BALANCE: 'failedToClearBalance', }, - TAX_RATES_BULK_ACTION_TYPES: { - DELETE: 'delete', - DISABLE: 'disable', - ENABLE: 'enable', - }, COLLECTION_KEYS: { DESCRIPTION: 'description', REIMBURSER: 'reimburser', @@ -2256,6 +2296,7 @@ const CONST = { LOGIN_CHARACTER_LIMIT: 254, CATEGORY_NAME_LIMIT: 256, TAG_NAME_LIMIT: 256, + WORKSPACE_REPORT_FIELD_POLICY_MAX_LENGTH: 256, REPORT_NAME_LIMIT: 100, TITLE_CHARACTER_LIMIT: 100, DESCRIPTION_LIMIT: 500, @@ -4078,13 +4119,13 @@ const CONST = { type: 'setupCategories', autoCompleted: false, title: 'Set up categories', - description: + description: ({workspaceLink}: {workspaceLink: string}) => '*Set up categories* so your team can code expenses for easy reporting.\n' + '\n' + 'Here’s how to set up categories:\n' + '\n' + '1. Click your profile picture.\n' + - '2. Go to *Workspaces* > [your workspace].\n' + + `2. Go to [*Workspaces* > [your workspace]](${workspaceLink}).\n` + '3. Click *Categories*.\n' + '4. Enable and disable default categories.\n' + '5. Click *Add categories* to make your own.\n' + @@ -4095,13 +4136,13 @@ const CONST = { type: 'addExpenseApprovals', autoCompleted: false, title: 'Add expense approvals', - description: + description: ({workspaceLink}: {workspaceLink: string}) => '*Add expense approvals* to review your team’s spend and keep it under control.\n' + '\n' + 'Here’s how to add expense approvals:\n' + '\n' + '1. Click your profile picture.\n' + - '2. Go to *Workspaces* > [your workspace].\n' + + `2. Go to [*Workspaces* > [your workspace]](${workspaceLink}).\n` + '3. Click *More features*.\n' + '4. Enable *Workflows*.\n' + '5. In *Workflows*, enable *Add approvals*.\n' + @@ -4112,13 +4153,13 @@ const CONST = { type: 'inviteTeam', autoCompleted: false, title: 'Invite your team', - description: + description: ({workspaceLink}: {workspaceLink: string}) => '*Invite your team* to Expensify so they can start tracking expenses today.\n' + '\n' + 'Here’s how to invite your team:\n' + '\n' + '1. Click your profile picture.\n' + - '2. Go to *Workspaces* > [your workspace].\n' + + `2. Go to [*Workspaces* > [your workspace]](${workspaceLink}).\n` + '3. Click *Members* > *Invite member*.\n' + '4. Enter emails or phone numbers. \n' + '5. Add an invite message if you want.\n' + @@ -5045,10 +5086,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', }, @@ -5079,6 +5122,12 @@ const CONST = { }, EXCLUDE_FROM_LAST_VISITED_PATH: [SCREENS.NOT_FOUND, SCREENS.SAML_SIGN_IN, SCREENS.VALIDATE_LOGIN] as string[], + + REPORT_FIELD_TYPES: { + TEXT: 'text', + DATE: 'date', + LIST: 'dropdown', + }, } as const; type Country = keyof typeof CONST.ALL_COUNTRIES; diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 5088c1d3158f..906f8ef7095e 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -359,12 +359,17 @@ const ONYXKEYS = { /** Holds the checks used while transferring the ownership of the workspace */ POLICY_OWNERSHIP_CHANGE_CHECKS: 'policyOwnershipChangeChecks', + // These statuses below are in separate keys on purpose - it allows us to have different behaviours of the banner based on the status + /** Indicates whether ClearOutstandingBalance failed */ SUBSCRIPTION_RETRY_BILLING_STATUS_FAILED: 'subscriptionRetryBillingStatusFailed', /** Indicates whether ClearOutstandingBalance was successful */ SUBSCRIPTION_RETRY_BILLING_STATUS_SUCCESSFUL: 'subscriptionRetryBillingStatusSuccessful', + /** Indicates whether ClearOutstandingBalance is pending */ + SUBSCRIPTION_RETRY_BILLING_STATUS_PENDING: 'subscriptionRetryBillingStatusPending', + /** Stores info during review duplicates flow */ REVIEW_DUPLICATES: 'reviewDuplicates', @@ -400,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_', @@ -454,6 +460,8 @@ const ONYXKEYS = { WORKSPACE_RATE_AND_UNIT_FORM_DRAFT: 'workspaceRateAndUnitFormDraft', WORKSPACE_TAX_CUSTOM_NAME: 'workspaceTaxCustomName', WORKSPACE_TAX_CUSTOM_NAME_DRAFT: 'workspaceTaxCustomNameDraft', + WORKSPACE_REPORT_FIELDS_FORM: 'workspaceReportFieldsForm', + WORKSPACE_REPORT_FIELDS_FORM_DRAFT: 'workspaceReportFieldsFormDraft', POLICY_CREATE_DISTANCE_RATE_FORM: 'policyCreateDistanceRateForm', POLICY_CREATE_DISTANCE_RATE_FORM_DRAFT: 'policyCreateDistanceRateFormDraft', POLICY_DISTANCE_RATE_EDIT_FORM: 'policyDistanceRateEditForm', @@ -552,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; @@ -564,6 +574,7 @@ type OnyxFormValuesMapping = { [ONYXKEYS.FORMS.WORKSPACE_TAG_FORM]: FormTypes.WorkspaceTagForm; [ONYXKEYS.FORMS.WORKSPACE_RATE_AND_UNIT_FORM]: FormTypes.WorkspaceRateAndUnitForm; [ONYXKEYS.FORMS.WORKSPACE_TAX_CUSTOM_NAME]: FormTypes.WorkspaceTaxCustomName; + [ONYXKEYS.FORMS.WORKSPACE_REPORT_FIELDS_FORM]: FormTypes.WorkspaceReportFieldsForm; [ONYXKEYS.FORMS.CLOSE_ACCOUNT_FORM]: FormTypes.CloseAccountForm; [ONYXKEYS.FORMS.PROFILE_SETTINGS_FORM]: FormTypes.ProfileSettingsForm; [ONYXKEYS.FORMS.DISPLAY_NAME_FORM]: FormTypes.DisplayNameForm; @@ -614,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 = { @@ -638,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; @@ -781,6 +794,7 @@ type OnyxValuesMapping = { [ONYXKEYS.NVP_QUICK_ACTION_GLOBAL_CREATE]: OnyxTypes.QuickAction; [ONYXKEYS.SUBSCRIPTION_RETRY_BILLING_STATUS_FAILED]: boolean; [ONYXKEYS.SUBSCRIPTION_RETRY_BILLING_STATUS_SUCCESSFUL]: boolean; + [ONYXKEYS.SUBSCRIPTION_RETRY_BILLING_STATUS_PENDING]: boolean; [ONYXKEYS.NVP_TRAVEL_SETTINGS]: OnyxTypes.TravelSettings; [ONYXKEYS.REVIEW_DUPLICATES]: OnyxTypes.ReviewDuplicates; [ONYXKEYS.ISSUE_NEW_EXPENSIFY_CARD]: OnyxTypes.IssueNewCard; @@ -799,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. */ @@ -806,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 45c56abc71d5..ef6ed2e264ca 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'; @@ -783,6 +783,26 @@ const ROUTES = { route: 'settings/workspaces/:policyID/reportFields', getRoute: (policyID: string) => `settings/workspaces/${policyID}/reportFields` as const, }, + WORKSPACE_CREATE_REPORT_FIELD: { + route: 'settings/workspaces/:policyID/reportFields/new', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/reportFields/new` as const, + }, + WORKSPACE_REPORT_FIELD_LIST_VALUES: { + route: 'settings/workspaces/:policyID/reportFields/new/listValues', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/reportFields/new/listValues` as const, + }, + WORKSPACE_REPORT_FIELD_ADD_VALUE: { + route: 'settings/workspaces/:policyID/reportFields/new/addValue', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/reportFields/new/addValue` as const, + }, + WORKSPACE_REPORT_FIELD_VALUE_SETTINGS: { + route: 'settings/workspaces/:policyID/reportFields/new/:valueIndex', + getRoute: (policyID: string, valueIndex: number) => `settings/workspaces/${policyID}/reportFields/new/${valueIndex}` as const, + }, + WORKSPACE_REPORT_FIELD_EDIT_VALUE: { + route: 'settings/workspaces/:policyID/reportFields/new/:valueIndex/edit', + getRoute: (policyID: string, valueIndex: number) => `settings/workspaces/${policyID}/reportFields/new/${valueIndex}/edit` as const, + }, WORKSPACE_EXPENSIFY_CARD: { route: 'settings/workspaces/:policyID/expensify-card', getRoute: (policyID: string) => `settings/workspaces/${policyID}/expensify-card` as const, @@ -940,10 +960,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, @@ -1001,6 +1038,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 8214c04cef75..9753b77a1db6 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -272,6 +272,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 +291,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 +318,11 @@ const SCREENS = { TAG_EDIT: 'Tag_Edit', TAXES: 'Workspace_Taxes', REPORT_FIELDS: 'Workspace_ReportFields', + REPORT_FIELDS_CREATE: 'Workspace_ReportFields_Create', + REPORT_FIELDS_LIST_VALUES: 'Workspace_ReportFields_ListValues', + REPORT_FIELDS_ADD_VALUE: 'Workspace_ReportFields_AddValue', + REPORT_FIELDS_VALUE_SETTINGS: 'Workspace_ReportFields_ValueSettings', + REPORT_FIELDS_EDIT_VALUE: 'Workspace_ReportFields_EditValue', TAX_EDIT: 'Workspace_Tax_Edit', TAX_NAME: 'Workspace_Tax_Name', TAX_VALUE: 'Workspace_Tax_Value', 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/Pager/AttachmentCarouselPagerContext.ts b/src/components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPagerContext.ts index 87a9108d5f2e..5893bcd9936e 100644 --- a/src/components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPagerContext.ts +++ b/src/components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPagerContext.ts @@ -1,5 +1,6 @@ import type {ForwardedRef} from 'react'; import {createContext} from 'react'; +import type {GestureType} from 'react-native-gesture-handler'; import type PagerView from 'react-native-pager-view'; import type {SharedValue} from 'react-native-reanimated'; import type {AttachmentSource} from '@components/Attachments/types'; @@ -17,16 +18,28 @@ type AttachmentCarouselPagerItems = { }; type AttachmentCarouselPagerContextValue = { - /** The list of items that are shown in the pager */ + /** List of attachments displayed in the pager */ pagerItems: AttachmentCarouselPagerItems[]; - /** The index of the active page */ + /** Index of the currently active page */ activePage: number; - pagerRef?: ForwardedRef; + + /** Ref to the active attachment */ + pagerRef?: ForwardedRef; + + /** Indicates if the pager is currently scrolling */ isPagerScrolling: SharedValue; + + /** Indicates if scrolling is enabled for the attachment */ isScrollEnabled: SharedValue; + + /** Function to call after a tap event */ onTap: () => void; + + /** Function to call when the scale changes */ onScaleChanged: (scale: number) => void; + + /** Function to call after a swipe down event */ onSwipeDown: () => void; }; diff --git a/src/components/Attachments/AttachmentCarousel/Pager/index.tsx b/src/components/Attachments/AttachmentCarousel/Pager/index.tsx index b7ef9309eb10..f16ba2c53ae8 100644 --- a/src/components/Attachments/AttachmentCarousel/Pager/index.tsx +++ b/src/components/Attachments/AttachmentCarousel/Pager/index.tsx @@ -1,4 +1,4 @@ -import type {ForwardedRef} from 'react'; +import type {ForwardedRef, SetStateAction} from 'react'; import React, {useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react'; import type {NativeSyntheticEvent} from 'react-native'; import {View} from 'react-native'; @@ -8,6 +8,7 @@ import type {PagerViewProps} from 'react-native-pager-view'; import PagerView from 'react-native-pager-view'; import Animated, {useAnimatedProps, useSharedValue} from 'react-native-reanimated'; import CarouselItem from '@components/Attachments/AttachmentCarousel/CarouselItem'; +import useCarouselContextEvents from '@components/Attachments/AttachmentCarousel/useCarouselContextEvents'; import type {Attachment, AttachmentSource} from '@components/Attachments/types'; import useThemeStyles from '@hooks/useThemeStyles'; import AttachmentCarouselPagerContext from './AttachmentCarouselPagerContext'; @@ -41,24 +42,21 @@ type AttachmentCarouselPagerProps = { >, ) => void; - /** - * A callback that can be used to toggle the attachment carousel arrows, when the scale of the image changes. - * @param showArrows If set, it will show/hide the arrows. If not set, it will toggle the arrows. - */ - onRequestToggleArrows: (showArrows?: boolean) => void; - /** A callback that is called when swipe-down-to-close gesture happens */ onClose: () => void; + + /** Sets the visibility of the arrows. */ + setShouldShowArrows: (show?: SetStateAction) => void; }; function AttachmentCarouselPager( - {items, activeSource, initialPage, onPageSelected, onRequestToggleArrows, onClose}: AttachmentCarouselPagerProps, + {items, activeSource, initialPage, setShouldShowArrows, onPageSelected, onClose}: AttachmentCarouselPagerProps, ref: ForwardedRef, ) { + const {handleTap, handleScaleChange} = useCarouselContextEvents(setShouldShowArrows); const styles = useThemeStyles(); const pagerRef = useRef(null); - const scale = useRef(1); const isPagerScrolling = useSharedValue(false); const isScrollEnabled = useSharedValue(true); @@ -80,42 +78,6 @@ function AttachmentCarouselPager( /** The `pagerItems` object that passed down to the context. Later used to detect current page, whether it's a single image gallery etc. */ const pagerItems = useMemo(() => items.map((item, index) => ({source: item.source, index, isActive: index === activePageIndex})), [activePageIndex, items]); - /** - * This callback is passed to the MultiGestureCanvas/Lightbox through the AttachmentCarouselPagerContext. - * It is used to react to zooming/pinching and (mostly) enabling/disabling scrolling on the pager, - * as well as enabling/disabling the carousel buttons. - */ - const handleScaleChange = useCallback( - (newScale: number) => { - if (newScale === scale.current) { - return; - } - - scale.current = newScale; - - const newIsScrollEnabled = newScale === 1; - if (isScrollEnabled.value === newIsScrollEnabled) { - return; - } - - isScrollEnabled.value = newIsScrollEnabled; - onRequestToggleArrows(newIsScrollEnabled); - }, - [isScrollEnabled, onRequestToggleArrows], - ); - - /** - * This callback is passed to the MultiGestureCanvas/Lightbox through the AttachmentCarouselPagerContext. - * It is used to trigger touch events on the pager when the user taps on the MultiGestureCanvas/Lightbox. - */ - const handleTap = useCallback(() => { - if (!isScrollEnabled.value) { - return; - } - - onRequestToggleArrows(); - }, [isScrollEnabled.value, onRequestToggleArrows]); - const extractItemKey = useCallback( (item: Attachment, index: number) => typeof item.source === 'string' || typeof item.source === 'number' ? `source-${item.source}` : `reportActionID-${item.reportActionID}` ?? `index-${index}`, diff --git a/src/components/Attachments/AttachmentCarousel/index.native.tsx b/src/components/Attachments/AttachmentCarousel/index.native.tsx index 15740725c42e..243fc52f1f5d 100644 --- a/src/components/Attachments/AttachmentCarousel/index.native.tsx +++ b/src/components/Attachments/AttachmentCarousel/index.native.tsx @@ -96,22 +96,6 @@ function AttachmentCarousel({report, reportActions, parentReportActions, source, [autoHideArrows, page, updatePage], ); - /** - * Toggles the arrows visibility - * @param {Boolean} showArrows if showArrows is passed, it will set the visibility to the passed value - */ - const toggleArrows = useCallback( - (showArrows?: boolean) => { - if (showArrows === undefined) { - setShouldShowArrows((prevShouldShowArrows) => !prevShouldShowArrows); - return; - } - - setShouldShowArrows(showArrows); - }, - [setShouldShowArrows], - ); - const containerStyles = [styles.flex1, styles.attachmentCarouselContainer]; if (page == null) { @@ -147,7 +131,7 @@ function AttachmentCarousel({report, reportActions, parentReportActions, source, items={attachments} initialPage={page} activeSource={activeSource} - onRequestToggleArrows={toggleArrows} + setShouldShowArrows={setShouldShowArrows} onPageSelected={({nativeEvent: {position: newPage}}) => updatePage(newPage)} onClose={onClose} ref={pagerRef} diff --git a/src/components/Attachments/AttachmentCarousel/index.tsx b/src/components/Attachments/AttachmentCarousel/index.tsx index eeac97bc5fa5..36abe1e2e5ed 100644 --- a/src/components/Attachments/AttachmentCarousel/index.tsx +++ b/src/components/Attachments/AttachmentCarousel/index.tsx @@ -1,10 +1,12 @@ import isEqual from 'lodash/isEqual'; -import React, {useCallback, useEffect, useMemo, useState} from 'react'; +import type {MutableRefObject} from 'react'; +import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import type {ListRenderItemInfo} from 'react-native'; import {Keyboard, PixelRatio, View} from 'react-native'; +import type {GestureType} from 'react-native-gesture-handler'; import {Gesture, GestureDetector} from 'react-native-gesture-handler'; import {withOnyx} from 'react-native-onyx'; -import Animated, {scrollTo, useAnimatedRef} from 'react-native-reanimated'; +import Animated, {scrollTo, useAnimatedRef, useSharedValue} from 'react-native-reanimated'; import type {Attachment, AttachmentSource} from '@components/Attachments/types'; import BlockingView from '@components/BlockingViews/BlockingView'; import * as Illustrations from '@components/Icon/Illustrations'; @@ -22,8 +24,10 @@ import CarouselActions from './CarouselActions'; import CarouselButtons from './CarouselButtons'; import CarouselItem from './CarouselItem'; import extractAttachments from './extractAttachments'; +import AttachmentCarouselPagerContext from './Pager/AttachmentCarouselPagerContext'; import type {AttachmentCaraouselOnyxProps, AttachmentCarouselProps, UpdatePageProps} from './types'; import useCarouselArrows from './useCarouselArrows'; +import useCarouselContextEvents from './useCarouselContextEvents'; const viewabilityConfig = { // To facilitate paging through the attachments, we want to consider an item "viewable" when it is @@ -33,13 +37,15 @@ const viewabilityConfig = { const MIN_FLING_VELOCITY = 500; -function AttachmentCarousel({report, reportActions, parentReportActions, source, onNavigate, setDownloadButtonVisibility, type, accountID}: AttachmentCarouselProps) { +function AttachmentCarousel({report, reportActions, parentReportActions, source, onNavigate, setDownloadButtonVisibility, type, accountID, onClose}: AttachmentCarouselProps) { const theme = useTheme(); const {translate} = useLocalize(); const {isSmallScreenWidth, windowWidth} = useWindowDimensions(); const styles = useThemeStyles(); const {isFullScreenRef} = useFullScreenContext(); const scrollRef = useAnimatedRef>>(); + const nope = useSharedValue(false); + const pagerRef = useRef(null); const canUseTouchScreen = DeviceCapabilities.canUseTouchScreen(); @@ -52,6 +58,14 @@ function AttachmentCarousel({report, reportActions, parentReportActions, source, const [attachments, setAttachments] = useState([]); const [activeSource, setActiveSource] = useState(source); const {shouldShowArrows, setShouldShowArrows, autoHideArrows, cancelAutoHideArrows} = useCarouselArrows(); + const {handleTap, handleScaleChange, scale} = useCarouselContextEvents(setShouldShowArrows); + + useEffect(() => { + if (!canUseTouchScreen) { + return; + } + setShouldShowArrows(true); + }, [canUseTouchScreen, page, setShouldShowArrows]); const compareImage = useCallback((attachment: Attachment) => attachment.source === source, [source]); @@ -121,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); @@ -169,6 +183,20 @@ function AttachmentCarousel({report, reportActions, parentReportActions, source, [cellWidth], ); + const context = useMemo( + () => ({ + pagerItems: [{source, index: 0, isActive: true}], + activePage: 0, + pagerRef, + isPagerScrolling: nope, + isScrollEnabled: nope, + onTap: handleTap, + onScaleChanged: handleScaleChange, + onSwipeDown: onClose, + }), + [source, nope, handleTap, handleScaleChange, onClose], + ); + /** Defines how a single attachment should be rendered */ const renderItem = useCallback( ({item}: ListRenderItemInfo) => ( @@ -176,20 +204,30 @@ function AttachmentCarousel({report, reportActions, parentReportActions, source, setShouldShowArrows((oldState) => !oldState) : undefined} + onPress={canUseTouchScreen ? handleTap : undefined} isModalHovered={shouldShowArrows} /> ), - [activeSource, canUseTouchScreen, cellWidth, setShouldShowArrows, shouldShowArrows, styles.h100], + [activeSource, canUseTouchScreen, cellWidth, handleTap, shouldShowArrows, styles.h100], ); /** Pan gesture handing swiping through attachments on touch screen devices */ const pan = useMemo( () => Gesture.Pan() .enabled(canUseTouchScreen) - .onUpdate(({translationX}) => scrollTo(scrollRef, page * cellWidth - translationX, 0, false)) + .onUpdate(({translationX}) => { + if (scale.current !== 1) { + return; + } + + scrollTo(scrollRef, page * cellWidth - translationX, 0, false); + }) .onEnd(({translationX, velocityX}) => { + if (scale.current !== 1) { + return; + } + let newIndex; if (velocityX > MIN_FLING_VELOCITY) { // User flung to the right @@ -204,8 +242,9 @@ function AttachmentCarousel({report, reportActions, parentReportActions, source, } scrollTo(scrollRef, newIndex * cellWidth, 0, true); - }), - [attachments.length, canUseTouchScreen, cellWidth, page, scrollRef], + }) + .withRef(pagerRef as MutableRefObject), + [attachments.length, canUseTouchScreen, cellWidth, page, scale, scrollRef], ); return ( @@ -233,27 +272,28 @@ function AttachmentCarousel({report, reportActions, parentReportActions, source, autoHideArrow={autoHideArrows} cancelAutoHideArrow={cancelAutoHideArrows} /> - - - - + + + + + diff --git a/src/components/Attachments/AttachmentCarousel/useCarouselArrows.ts b/src/components/Attachments/AttachmentCarousel/useCarouselArrows.ts index 12ca3db4e2ff..a7ce0f93114b 100644 --- a/src/components/Attachments/AttachmentCarousel/useCarouselArrows.ts +++ b/src/components/Attachments/AttachmentCarousel/useCarouselArrows.ts @@ -32,6 +32,9 @@ function useCarouselArrows() { }, CONST.ARROW_HIDE_DELAY); }, [canUseTouchScreen, cancelAutoHideArrows]); + /** + * Sets the visibility of the arrows. + */ const setShouldShowArrows = useCallback( (show: SetStateAction = true) => { setShouldShowArrowsInternal(show); diff --git a/src/components/Attachments/AttachmentCarousel/useCarouselContextEvents.ts b/src/components/Attachments/AttachmentCarousel/useCarouselContextEvents.ts new file mode 100644 index 000000000000..d516879322ea --- /dev/null +++ b/src/components/Attachments/AttachmentCarousel/useCarouselContextEvents.ts @@ -0,0 +1,63 @@ +import {useCallback, useRef} from 'react'; +import type {SetStateAction} from 'react'; +import {useSharedValue} from 'react-native-reanimated'; + +function useCarouselContextEvents(setShouldShowArrows: (show?: SetStateAction) => void) { + const scale = useRef(1); + const isScrollEnabled = useSharedValue(true); + + /** + * Toggles the arrows visibility + */ + const onRequestToggleArrows = useCallback( + (showArrows?: boolean) => { + if (showArrows === undefined) { + setShouldShowArrows((prevShouldShowArrows) => !prevShouldShowArrows); + return; + } + + setShouldShowArrows(showArrows); + }, + [setShouldShowArrows], + ); + + /** + * This callback is passed to the MultiGestureCanvas/Lightbox through the AttachmentCarouselPagerContext. + * It is used to react to zooming/pinching and (mostly) enabling/disabling scrolling on the pager, + * as well as enabling/disabling the carousel buttons. + */ + const handleScaleChange = useCallback( + (newScale: number) => { + if (newScale === scale.current) { + return; + } + + scale.current = newScale; + + const newIsScrollEnabled = newScale === 1; + if (isScrollEnabled.value === newIsScrollEnabled) { + return; + } + + isScrollEnabled.value = newIsScrollEnabled; + onRequestToggleArrows(newIsScrollEnabled); + }, + [isScrollEnabled, onRequestToggleArrows], + ); + + /** + * This callback is passed to the MultiGestureCanvas/Lightbox through the AttachmentCarouselPagerContext. + * It is used to trigger touch events on the pager when the user taps on the MultiGestureCanvas/Lightbox. + */ + const handleTap = useCallback(() => { + if (!isScrollEnabled.value) { + return; + } + + onRequestToggleArrows(); + }, [isScrollEnabled.value, onRequestToggleArrows]); + + return {handleTap, handleScaleChange, scale}; +} + +export default useCarouselContextEvents; diff --git a/src/components/ButtonWithDropdownMenu/types.ts b/src/components/ButtonWithDropdownMenu/types.ts index 702f0380ceef..d1eedd560694 100644 --- a/src/components/ButtonWithDropdownMenu/types.ts +++ b/src/components/ButtonWithDropdownMenu/types.ts @@ -10,9 +10,9 @@ type PaymentType = DeepValueOf; -type WorkspaceDistanceRatesBulkActionType = DeepValueOf; +type WorkspaceDistanceRatesBulkActionType = DeepValueOf; -type WorkspaceTaxRatesBulkActionType = DeepValueOf; +type WorkspaceTaxRatesBulkActionType = DeepValueOf; type DropdownOption = { value: TValueType; 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/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/ConnectToQuickbooksOnlineButton/index.tsx b/src/components/ConnectToQuickbooksOnlineButton/index.tsx index 71f1fba91187..50ee9165b8a3 100644 --- a/src/components/ConnectToQuickbooksOnlineButton/index.tsx +++ b/src/components/ConnectToQuickbooksOnlineButton/index.tsx @@ -40,6 +40,8 @@ function ConnectToQuickbooksOnlineButton({policyID, shouldDisconnectIntegrationB {shouldDisconnectIntegrationBeforeConnecting && integrationToDisconnect && isDisconnectModalOpen && ( { + // Since QBO doesn't support Taxes, we should disable them from the LHN when connecting to QBO + PolicyAction.enablePolicyTaxes(policyID, false); removePolicyConnection(policyID, integrationToDisconnect); Link.openLink(getQuickBooksOnlineSetupLink(policyID), environmentURL); setIsDisconnectModalOpen(false); 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 ( backgroundImages[colorCode], [colorCode]); @@ -141,6 +143,14 @@ function EReceiptThumbnail({transaction, borderRadius, fileExtension, isReceiptT fill={primaryColor} /> ) : null} + {tripIcon ? ( + + ) : null} diff --git a/src/components/FlatList/index.tsx b/src/components/FlatList/index.tsx index f54eddcbeb79..d3e0459a11bb 100644 --- a/src/components/FlatList/index.tsx +++ b/src/components/FlatList/index.tsx @@ -54,7 +54,6 @@ function MVCPFlatList({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/FocusTrap/FocusTrapForScreen/index.web.tsx b/src/components/FocusTrap/FocusTrapForScreen/index.web.tsx index 6a1409ab4a93..d81293729b94 100644 --- a/src/components/FocusTrap/FocusTrapForScreen/index.web.tsx +++ b/src/components/FocusTrap/FocusTrapForScreen/index.web.tsx @@ -1,12 +1,14 @@ import {useFocusEffect, useIsFocused, useRoute} from '@react-navigation/native'; import FocusTrap from 'focus-trap-react'; import React, {useCallback, useMemo} from 'react'; +import {useOnyx} from 'react-native-onyx'; import BOTTOM_TAB_SCREENS from '@components/FocusTrap/BOTTOM_TAB_SCREENS'; -import SCREENS_WITH_AUTOFOCUS from '@components/FocusTrap/SCREENS_WITH_AUTOFOCUS'; +import getScreenWithAutofocus from '@components/FocusTrap/SCREENS_WITH_AUTOFOCUS'; import sharedTrapStack from '@components/FocusTrap/sharedTrapStack'; import TOP_TAB_SCREENS from '@components/FocusTrap/TOP_TAB_SCREENS'; import WIDE_LAYOUT_INACTIVE_SCREENS from '@components/FocusTrap/WIDE_LAYOUT_INACTIVE_SCREENS'; import useWindowDimensions from '@hooks/useWindowDimensions'; +import ONYXKEYS from '@src/ONYXKEYS'; import type FocusTrapProps from './FocusTrapProps'; let activeRouteName = ''; @@ -14,6 +16,8 @@ function FocusTrapForScreen({children}: FocusTrapProps) { const isFocused = useIsFocused(); const route = useRoute(); const {isSmallScreenWidth} = useWindowDimensions(); + const [isAuthenticated] = useOnyx(ONYXKEYS.SESSION, {selector: (session) => !!session?.authToken}); + const screensWithAutofocus = useMemo(() => getScreenWithAutofocus(!!isAuthenticated), [isAuthenticated]); const isActive = useMemo(() => { // Focus trap can't be active on bottom tab screens because it would block access to the tab bar. @@ -49,13 +53,13 @@ function FocusTrapForScreen({children}: FocusTrapProps) { fallbackFocus: document.body, // We don't want to ovverride autofocus on these screens. initialFocus: () => { - if (SCREENS_WITH_AUTOFOCUS.includes(activeRouteName)) { + if (screensWithAutofocus.includes(activeRouteName)) { return false; } return undefined; }, setReturnFocus: (element) => { - if (SCREENS_WITH_AUTOFOCUS.includes(activeRouteName)) { + if (screensWithAutofocus.includes(activeRouteName)) { return false; } return element; diff --git a/src/components/FocusTrap/SCREENS_WITH_AUTOFOCUS.ts b/src/components/FocusTrap/SCREENS_WITH_AUTOFOCUS.ts index 2a77b52e3116..7af327d35ac4 100644 --- a/src/components/FocusTrap/SCREENS_WITH_AUTOFOCUS.ts +++ b/src/components/FocusTrap/SCREENS_WITH_AUTOFOCUS.ts @@ -1,4 +1,5 @@ import {CENTRAL_PANE_WORKSPACE_SCREENS} from '@libs/Navigation/AppNavigator/Navigators/FullScreenNavigator'; +import NAVIGATORS from '@src/NAVIGATORS'; import SCREENS from '@src/SCREENS'; const SCREENS_WITH_AUTOFOCUS: string[] = [ @@ -10,6 +11,14 @@ const SCREENS_WITH_AUTOFOCUS: string[] = [ SCREENS.SETTINGS.PROFILE.PRONOUNS, SCREENS.NEW_TASK.DETAILS, SCREENS.MONEY_REQUEST.CREATE, + SCREENS.SIGN_IN_ROOT, ]; -export default SCREENS_WITH_AUTOFOCUS; +function getScreenWithAutofocus(isAuthenticated: boolean) { + if (!isAuthenticated) { + return [...SCREENS_WITH_AUTOFOCUS, NAVIGATORS.BOTTOM_TAB_NAVIGATOR]; + } + return SCREENS_WITH_AUTOFOCUS; +} + +export default getScreenWithAutofocus; diff --git a/src/components/Form/types.ts b/src/components/Form/types.ts index 6245fdcf7b49..afbe2bb124b5 100644 --- a/src/components/Form/types.ts +++ b/src/components/Form/types.ts @@ -49,12 +49,14 @@ type ValidInputs = | typeof AddPlaidBankAccount | typeof EmojiPickerButtonDropdown; -type ValueTypeKey = 'string' | 'boolean' | 'date' | 'country'; +type ValueTypeKey = 'string' | 'boolean' | 'date' | 'country' | 'reportFields' | 'disabledListValues'; type ValueTypeMap = { string: string; boolean: boolean; date: Date; country: Country | ''; + reportFields: string[]; + disabledListValues: boolean[]; }; type FormValue = ValueOf; 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/ImageView/index.tsx b/src/components/ImageView/index.tsx index c74d9bd5aa52..e12be53d01ae 100644 --- a/src/components/ImageView/index.tsx +++ b/src/components/ImageView/index.tsx @@ -7,6 +7,7 @@ import FullscreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; import Image from '@components/Image'; import RESIZE_MODES from '@components/Image/resizeModes'; import type {ImageOnLoadEvent} from '@components/Image/types'; +import Lightbox from '@components/Lightbox'; import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback'; import useNetwork from '@hooks/useNetwork'; import useStyleUtils from '@hooks/useStyleUtils'; @@ -200,25 +201,11 @@ function ImageView({isAuthTokenRequired = false, url, fileName, onError}: ImageV if (canUseTouchScreen) { return ( - - 1 ? RESIZE_MODES.center : RESIZE_MODES.contain} - onLoadStart={imageLoadingStart} - onLoad={imageLoad} - onError={onError} - /> - {((isLoading && (!isOffline || isLocalFile)) || (!isLoading && zoomScale === 0)) && } - {isLoading && !isLocalFile && } - + ); } return ( 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 9fd18524158d..bf12c95825dc 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, @@ -702,6 +706,7 @@ function MenuItem( )} + {!!furtherDetailsComponent && {furtherDetailsComponent}} {titleComponent} diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index 28c72472f8ce..1a6fa5326234 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -137,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) => { + const confirmPayment = (type?: PaymentMethodType | undefined, payAsBusiness?: boolean) => { if (!type || !chatReport) { return; } @@ -146,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); + IOU.payInvoice(type, chatReport, moneyRequestReport, payAsBusiness); } else { IOU.payMoneyRequest(type, chatReport, moneyRequestReport, true); } @@ -368,6 +368,7 @@ function MoneyReportHeader({policy, report: moneyRequestReport, transactionThrea paymentType={paymentType} chatReport={chatReport} moneyRequestReport={moneyRequestReport} + transactionCount={transactionIDs.length} /> )} ; + /** 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/MultiGestureCanvas/index.tsx b/src/components/MultiGestureCanvas/index.tsx index 31a1f7a2c3d8..6b93e2e125e0 100644 --- a/src/components/MultiGestureCanvas/index.tsx +++ b/src/components/MultiGestureCanvas/index.tsx @@ -1,6 +1,7 @@ import type {ForwardedRef} from 'react'; import React, {useEffect, useMemo, useRef} from 'react'; import {View} from 'react-native'; +import type {GestureType} from 'react-native-gesture-handler'; import {Gesture, GestureDetector} from 'react-native-gesture-handler'; import type {GestureRef} from 'react-native-gesture-handler/lib/typescript/handlers/gestures/gesture'; import type PagerView from 'react-native-pager-view'; @@ -40,7 +41,7 @@ type MultiGestureCanvasProps = ChildrenProps & { shouldDisableTransformationGestures?: SharedValue; /** If there is a pager wrapping the canvas, we need to disable the pan gesture in case the pager is swiping */ - pagerRef?: ForwardedRef; // TODO: For TS migration: Exclude + pagerRef?: ForwardedRef; // TODO: For TS migration: Exclude /** Handles scale changed event */ onScaleChanged?: OnScaleChangedCallback; @@ -48,6 +49,7 @@ type MultiGestureCanvasProps = ChildrenProps & { /** Handles scale changed event */ onTap?: OnTapCallback; + /** Handles swipe down event */ onSwipeDown?: OnSwipeDownCallback; }; @@ -242,11 +244,12 @@ function MultiGestureCanvas({ e.preventDefault()} style={StyleUtils.getFullscreenCenteredContentStyles()} > {children} diff --git a/src/components/MultiGestureCanvas/usePanGesture.ts b/src/components/MultiGestureCanvas/usePanGesture.ts index 903f384dd525..636913fdf05d 100644 --- a/src/components/MultiGestureCanvas/usePanGesture.ts +++ b/src/components/MultiGestureCanvas/usePanGesture.ts @@ -3,6 +3,7 @@ import {Dimensions} from 'react-native'; import type {PanGesture} from 'react-native-gesture-handler'; import {Gesture} from 'react-native-gesture-handler'; import {runOnJS, useDerivedValue, useSharedValue, useWorkletCallback, withDecay, withSpring} from 'react-native-reanimated'; +import * as Browser from '@libs/Browser'; import {SPRING_CONFIG} from './constants'; import type {MultiGestureCanvasVariables} from './types'; import * as MultiGestureCanvasUtils from './utils'; @@ -57,6 +58,8 @@ const usePanGesture = ({ const panVelocityX = useSharedValue(0); const panVelocityY = useSharedValue(0); + const isMobileBrowser = Browser.isMobile(); + // Disable "swipe down to close" gesture when content is bigger than the canvas const enableSwipeDownToClose = useDerivedValue(() => canvasSize.height < zoomedContentHeight.value, [canvasSize.height]); @@ -207,7 +210,9 @@ const usePanGesture = ({ panVelocityY.value = evt.velocityY; if (!isSwipingDownToClose.value) { - panTranslateX.value += evt.changeX; + if (!isMobileBrowser || (isMobileBrowser && zoomScale.value !== 1)) { + panTranslateX.value += evt.changeX; + } } if (enableSwipeDownToClose.value || isSwipingDownToClose.value) { diff --git a/src/components/OfflineWithFeedback.tsx b/src/components/OfflineWithFeedback.tsx index 28e1d81b30e4..ac9eda4043e8 100644 --- a/src/components/OfflineWithFeedback.tsx +++ b/src/components/OfflineWithFeedback.tsx @@ -1,6 +1,6 @@ import {mapValues} from 'lodash'; import React, {useCallback} from 'react'; -import type {ImageStyle, StyleProp, TextStyle, ViewStyle} from 'react-native'; +import type {StyleProp, ViewStyle} from 'react-native'; import {View} from 'react-native'; import useNetwork from '@hooks/useNetwork'; import useStyleUtils from '@hooks/useStyleUtils'; @@ -60,7 +60,7 @@ type OfflineWithFeedbackProps = ChildrenProps & { canDismissError?: boolean; }; -type StrikethroughProps = Partial & {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 ( { + if (nonHeldAmount) { + return translate(isApprove ? 'iou.confirmApprovalAmount' : 'iou.confirmPayAmount'); + } + return translate(isApprove ? 'iou.confirmApprovalAllHoldAmount' : 'iou.confirmPayAllHoldAmount', {transactionCount}); + }, [nonHeldAmount, transactionCount, translate, isApprove]); + return ( onSubmit(false)} diff --git a/src/components/ReportActionItem/MoneyReportView.tsx b/src/components/ReportActionItem/MoneyReportView.tsx index a724fd27f134..4bd6d4103bee 100644 --- a/src/components/ReportActionItem/MoneyReportView.tsx +++ b/src/components/ReportActionItem/MoneyReportView.tsx @@ -64,8 +64,11 @@ function MoneyReportView({report, policy}: MoneyReportViewProps) { <> {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/ReportPreview.tsx b/src/components/ReportActionItem/ReportPreview.tsx index c796a267fd01..9693b982ec4a 100644 --- a/src/components/ReportActionItem/ReportPreview.tsx +++ b/src/components/ReportActionItem/ReportPreview.tsx @@ -138,6 +138,7 @@ 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); @@ -177,7 +178,7 @@ function ReportPreview({ [chatReport?.isOwnPolicyExpenseChat, policy?.harvesting?.enabled], ); - const confirmPayment = (type: PaymentMethodType | undefined) => { + const confirmPayment = (type: PaymentMethodType | undefined, payAsBusiness?: boolean) => { if (!type) { return; } @@ -187,7 +188,7 @@ function ReportPreview({ setIsHoldMenuVisible(true); } else if (chatReport && iouReport) { if (ReportUtils.isInvoiceReport(iouReport)) { - IOU.payInvoice(type, chatReport, iouReport); + IOU.payInvoice(type, chatReport, iouReport, payAsBusiness); } else { IOU.payMoneyRequest(type, chatReport, iouReport); } @@ -246,7 +247,16 @@ function ReportPreview({ if (isScanning) { return translate('common.receipt'); } - let payerOrApproverName = isPolicyExpenseChat ? ReportUtils.getPolicyName(chatReport) : ReportUtils.getDisplayNameForParticipant(managerID, true); + + let payerOrApproverName; + if (isPolicyExpenseChat) { + payerOrApproverName = ReportUtils.getPolicyName(chatReport); + } else if (isInvoiceRoom) { + payerOrApproverName = ReportUtils.getInvoicePayerName(chatReport); + } else { + payerOrApproverName = ReportUtils.getDisplayNameForParticipant(managerID, true); + } + if (isApproved) { return translate('iou.managerApproved', {manager: payerOrApproverName}); } @@ -454,6 +464,7 @@ function ReportPreview({ paymentType={paymentType} chatReport={chatReport} moneyRequestReport={iouReport} + transactionCount={numberOfRequests} /> )} 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 878d25da4af4..503f7d11d2da 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) { + if (item.isSelected && !selectedOptions.find((option) => option.text === item.text)) { selectedOptions.push(item); } }); diff --git a/src/components/SelectionList/Search/ReportListItem.tsx b/src/components/SelectionList/Search/ReportListItem.tsx index 553839ae8457..7119cee06cd9 100644 --- a/src/components/SelectionList/Search/ReportListItem.tsx +++ b/src/components/SelectionList/Search/ReportListItem.tsx @@ -88,8 +88,8 @@ function ReportListItem({ return null; } - const participantFrom = reportItem.transactions[0].from; - const participantTo = reportItem.transactions[0].to; + const participantFrom = reportItem.from; + const participantTo = reportItem.to; // These values should come as part of the item via SearchUtils.getSections() but ReportListItem is not yet 100% handled // This will be simplified in future once sorting of ReportListItem is done diff --git a/src/components/SelectionList/TableListItem.tsx b/src/components/SelectionList/TableListItem.tsx index 9fc138254f8b..83bc8df36571 100644 --- a/src/components/SelectionList/TableListItem.tsx +++ b/src/components/SelectionList/TableListItem.tsx @@ -43,7 +43,7 @@ function TableListItem({ return ( ({ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing disabled={isDisabled || item.isDisabledCheckbox} onPress={handleCheckboxPress} - style={[styles.cursorUnset, StyleUtils.getCheckboxPressableStyle(), item.isDisabledCheckbox && styles.cursorDisabled, styles.mr3]} + style={[styles.cursorUnset, StyleUtils.getCheckboxPressableStyle(), item.isDisabledCheckbox && styles.cursorDisabled, styles.mr3, item.cursorStyle]} > - + {item.isSelected && ( void; + onPress: (paymentType?: PaymentMethodType, payAsBusiness?: boolean) => void; /** The route to redirect if user does not have a payment method setup */ enablePaymentsRoute: EnablePaymentsRoute; @@ -143,6 +145,9 @@ 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. @@ -199,20 +204,39 @@ function SettlementButton({ } if (isInvoiceReport) { - 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 (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), + }, + ], + }); + } } if (shouldShowApproveButton) { @@ -226,7 +250,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, policyID, translate, shouldHidePaymentOptions, shouldShowApproveButton, shouldDisableApproveButton]); + }, [currency, formattedAmount, iouReport, chatReport, policyID, translate, shouldHidePaymentOptions, primaryPolicy, shouldShowApproveButton, shouldDisableApproveButton]); const selectPaymentType = (event: KYCFlowEvent, iouPaymentType: PaymentMethodType, triggerKYCFlow: TriggerKYCFlow) => { if (policy && SubscriptionUtils.shouldRestrictUserBillableActions(policy.id)) { @@ -259,7 +283,7 @@ function SettlementButton({ return ( onPress(paymentType)} 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/TestToolsModal.tsx b/src/components/TestToolsModal.tsx index ad1c65e76a4b..5c330bd700e0 100644 --- a/src/components/TestToolsModal.tsx +++ b/src/components/TestToolsModal.tsx @@ -31,7 +31,7 @@ type TestToolsModalOnyxProps = { type TestToolsModalProps = TestToolsModalOnyxProps; function TestToolsModal({isTestToolsModalOpen = false, shouldStoreLogs = false}: TestToolsModalProps) { - const {isDevelopment} = useEnvironment(); + const {isProduction} = useEnvironment(); const {windowWidth} = useWindowDimensions(); const StyleUtils = useStyleUtils(); const styles = useThemeStyles(); @@ -44,7 +44,6 @@ function TestToolsModal({isTestToolsModalOpen = false, shouldStoreLogs = false}: onClose={toggleTestToolsModal} > - {isDevelopment && } )} + {!isProduction && } ); diff --git a/src/components/TextPicker/TextSelectorModal.tsx b/src/components/TextPicker/TextSelectorModal.tsx index a867a7030b54..2016133559d4 100644 --- a/src/components/TextPicker/TextSelectorModal.tsx +++ b/src/components/TextPicker/TextSelectorModal.tsx @@ -6,6 +6,7 @@ import HeaderWithBackButton from '@components/HeaderWithBackButton'; import Modal from '@components/Modal'; import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; +import Text from '@components/Text'; import TextInput from '@components/TextInput'; import type {BaseTextInputRef} from '@components/TextInput/BaseTextInput/types'; import useLocalize from '@hooks/useLocalize'; @@ -14,7 +15,7 @@ import CONST from '@src/CONST'; import type {TextSelectorModalProps} from './types'; import usePaddingStyle from './usePaddingStyle'; -function TextSelectorModal({value, description = '', onValueSelected, isVisible, onClose, ...rest}: TextSelectorModalProps) { +function TextSelectorModal({value, description = '', subtitle, onValueSelected, isVisible, onClose, shouldClearOnClose, ...rest}: TextSelectorModalProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); @@ -24,6 +25,13 @@ function TextSelectorModal({value, description = '', onValueSelected, isVisible, const inputRef = useRef(null); const focusTimeoutRef = useRef(null); + const hide = useCallback(() => { + onClose(); + if (shouldClearOnClose) { + setValue(''); + } + }, [onClose, shouldClearOnClose]); + useFocusEffect( useCallback(() => { focusTimeoutRef.current = setTimeout(() => { @@ -44,8 +52,8 @@ function TextSelectorModal({value, description = '', onValueSelected, isVisible, + {!!subtitle && {subtitle}} & + + /** Whether to clear the input value when the modal closes */ + shouldClearOnClose?: boolean; +} & Pick & TextProps; type TextPickerProps = { @@ -39,7 +42,7 @@ type TextPickerProps = { /** Whether to show the tooltip text */ shouldShowTooltips?: boolean; -} & Pick & +} & Pick & TextProps; export type {TextSelectorModalProps, TextPickerProps}; 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/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 936941003073..190cfd3cf753 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'; @@ -16,6 +17,7 @@ import type { ChangePolicyParams, ChangeTypeParams, CharacterLimitParams, + ConfirmHoldExpenseParams, ConfirmThatParams, DateShouldBeAfterParams, DateShouldBeBeforeParams, @@ -355,6 +357,9 @@ export default { companyID: 'Company ID', userID: 'User ID', disable: 'Disable', + initialValue: 'Initial value', + currentDate: 'Current date', + value: 'Value', }, location: { useCurrent: 'Use current location', @@ -699,6 +704,7 @@ 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}`, @@ -788,8 +794,12 @@ export default { keepAll: 'Keep all', confirmApprove: 'Confirm approval amount', confirmApprovalAmount: "Approve what's not on hold, or approve the entire report.", + confirmApprovalAllHoldAmount: ({transactionCount}: ConfirmHoldExpenseParams) => + `${Str.pluralize('This expense is', 'These expenses are', transactionCount)} on hold. Do you want to approve anyway?`, confirmPay: 'Confirm payment amount', - confirmPayAmount: "Pay what's not on hold, or pay all out-of-pocket spend.", + confirmPayAmount: "Pay what's not on hold, or pay the entire report.", + confirmPayAllHoldAmount: ({transactionCount}: ConfirmHoldExpenseParams) => + `${Str.pluralize('This expense is', 'These expenses are', transactionCount)} on hold. Do you want to pay anyway?`, payOnly: 'Pay only', approveOnly: 'Approve only', holdEducationalTitle: 'This expense is on', @@ -972,6 +982,8 @@ export default { deviceCredentials: 'Device credentials', invalidate: 'Invalidate', destroy: 'Destroy', + maskExportOnyxStateData: 'Mask fragile user data while exporting Onyx state', + exportOnyxState: 'Export Onyx state', }, debugConsole: { saveLog: 'Save log', @@ -1987,7 +1999,7 @@ export default { reimburse: 'Reimbursements', categories: 'Categories', tags: 'Tags', - reportFields: 'Report Fields', + reportFields: 'Report fields', taxes: 'Taxes', bills: 'Bills', invoices: 'Invoices', @@ -2089,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', @@ -2161,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', @@ -2284,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', @@ -2292,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: { @@ -2390,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', @@ -2430,9 +2555,42 @@ export default { title: "You haven't created any report fields", subtitle: 'Add a custom field (text, date, or dropdown) that appears on reports.', }, - subtitle: "Report fields apply to all spend and can be helpful when you'd like to prompt for extra information", + subtitle: "Report fields apply to all spend and can be helpful when you'd like to prompt for extra information.", disableReportFields: 'Disable report fields', disableReportFieldsConfirmation: 'Are you sure? Text and date fields will be deleted, and lists will be disabled.', + textType: 'Text', + dateType: 'Date', + dropdownType: 'List', + textAlternateText: 'Add a field for free text input.', + dateAlternateText: 'Add a calendar for date selection.', + dropdownAlternateText: 'Add a list of options to choose from.', + nameInputSubtitle: 'Choose a name for the report field.', + typeInputSubtitle: 'Choose what type of report field to use.', + initialValueInputSubtitle: 'Enter a starting value to show in the report field.', + listValuesInputSubtitle: 'These values will appear in your report field dropdown. Enabled values can be selected by members.', + listInputSubtitle: 'These values will appear in your report field list. Enabled values can be selected by members.', + deleteValue: 'Delete value', + deleteValues: 'Delete values', + disableValue: 'Disable value', + disableValues: 'Disable values', + enableValue: 'Enable value', + enableValues: 'Enable values', + emptyReportFieldsValues: { + title: "You haven't created any list values", + subtitle: 'Add custom values to appear on reports.', + }, + deleteValuePrompt: 'Are you sure you want to delete this list value?', + deleteValuesPrompt: 'Are you sure you want to delete these list values?', + listValueRequiredError: 'Please enter a list value name', + existingListValueError: 'A list value with this name already exists', + editValue: 'Edit value', + listValues: 'List values', + addValue: 'Add value', + existingReportFieldNameError: 'A report field with this name already exists', + reportFieldNameRequiredError: 'Please enter a report field name', + reportFieldTypeRequiredError: 'Please choose a report field type', + reportFieldInitialValueRequiredError: 'Please choose a report field initial value', + genericFailureMessage: 'An error occurred while updating the report field. Please try again.', }, tags: { tagName: 'Tag name', @@ -2762,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', @@ -2844,6 +3004,8 @@ export default { editor: { descriptionInputLabel: 'Description', nameInputLabel: 'Name', + typeInputLabel: 'Type', + initialValueInputLabel: 'Initial value', nameInputHelpText: "This is the name you'll see on your workspace.", nameIsRequiredError: "You'll need to give your workspace a name.", currencyInputLabel: 'Default currency', diff --git a/src/languages/es.ts b/src/languages/es.ts index 59aad3275c41..442bfb1927f1 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -15,6 +15,7 @@ import type { ChangePolicyParams, ChangeTypeParams, CharacterLimitParams, + ConfirmHoldExpenseParams, ConfirmThatParams, DateShouldBeAfterParams, DateShouldBeBeforeParams, @@ -346,6 +347,9 @@ export default { companyID: 'Empresa ID', userID: 'Usuario ID', disable: 'Deshabilitar', + initialValue: 'Valor inicial', + currentDate: 'Fecha actual', + value: 'Valor', }, connectionComplete: { title: 'Conexión completa', @@ -693,6 +697,7 @@ 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}`, @@ -782,8 +787,20 @@ export default { keepAll: 'Mantener todos', confirmApprove: 'Confirmar importe a aprobar', confirmApprovalAmount: 'Aprueba lo que no está bloqueado, o aprueba todo el informe.', + confirmApprovalAllHoldAmount: ({transactionCount}: ConfirmHoldExpenseParams) => + `${Str.pluralize('Este gasto está bloqueado', 'Estos gastos están bloqueados', transactionCount)}. ¿Quieres ${Str.pluralize( + 'aprobar', + 'aprobarlos', + transactionCount, + )} de todos modos?`, confirmPay: 'Confirmar importe de pago', - confirmPayAmount: 'Paga lo que no está bloqueado, o paga todos los gastos por cuenta propia.', + confirmPayAmount: 'Paga lo que no está bloqueado, o paga el informe completo.', + confirmPayAllHoldAmount: ({transactionCount}: ConfirmHoldExpenseParams) => + `${Str.pluralize('Este gasto está bloqueado', 'Estos gastos están bloqueados', transactionCount)}. ¿Quieres ${Str.pluralize( + 'pagar', + 'pagarlo', + transactionCount, + )} de todos modos?`, payOnly: 'Solo pagar', approveOnly: 'Solo aprobar', hold: 'Bloquear', @@ -969,6 +986,8 @@ export default { deviceCredentials: 'Credenciales del dispositivo', invalidate: 'Invalidar', destroy: 'Destruir', + maskExportOnyxStateData: 'Enmascare los datos frágiles del usuario mientras exporta el estado Onyx', + exportOnyxState: 'Exportar estado Onyx', }, debugConsole: { saveLog: 'Guardar registro', @@ -2115,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', @@ -2193,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', @@ -2317,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', @@ -2325,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: { @@ -2420,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', @@ -2464,9 +2597,42 @@ export default { title: 'No has creado ningún campo de informe', subtitle: 'Añade un campo personalizado (texto, fecha o desplegable) que aparezca en los informes.', }, - subtitle: 'Los campos de informe se aplican a todos los gastos y pueden ser útiles cuando desees solicitar información adicional', + subtitle: 'Los campos de informe se aplican a todos los gastos y pueden ser útiles cuando quieras solicitar información adicional.', disableReportFields: 'Desactivar campos de informe', disableReportFieldsConfirmation: 'Estás seguro? Se eliminarán los campos de texto y fecha y se desactivarán las listas.', + textType: 'Texto', + dateType: 'Fecha', + dropdownType: 'Lista', + textAlternateText: 'Añade un campo para introducir texto libre.', + dateAlternateText: 'Añade un calendario para la selección de fechas.', + dropdownAlternateText: 'Añade una lista de opciones para elegir.', + nameInputSubtitle: 'Elige un nombre para el campo del informe.', + typeInputSubtitle: 'Elige qué tipo de campo de informe utilizar.', + initialValueInputSubtitle: 'Ingresa un valor inicial para mostrar en el campo del informe.', + listValuesInputSubtitle: 'Estos valores aparecerán en el desplegable del campo de tu informe. Los miembros pueden seleccionar los valores habilitados.', + listInputSubtitle: 'Estos valores aparecerán en la lista de campos de tu informe. Los miembros pueden seleccionar los valores habilitados.', + deleteValue: 'Eliminar valor', + deleteValues: 'Eliminar valores', + disableValue: 'Desactivar valor', + disableValues: 'Desactivar valores', + enableValue: 'Habilitar valor', + enableValues: 'Habilitar valores', + emptyReportFieldsValues: { + title: 'No has creado ningún valor en la lista', + subtitle: 'Añade valores personalizados para que aparezcan en los informes.', + }, + deleteValuePrompt: '¿Estás seguro de que quieres eliminar este valor de la lista?', + deleteValuesPrompt: '¿Estás seguro de que quieres eliminar estos valores de la lista?', + listValueRequiredError: 'Ingresa un nombre para el valor de la lista', + existingListValueError: 'Ya existe un valor en la lista con este nombre', + editValue: 'Editar valor', + listValues: 'Valores de la lista', + addValue: 'Añade valor', + existingReportFieldNameError: 'Ya existe un campo de informe con este nombre', + reportFieldNameRequiredError: 'Ingresa un nombre de campo de informe', + reportFieldTypeRequiredError: 'Elige un tipo de campo de informe', + reportFieldInitialValueRequiredError: 'Elige un valor inicial de campo de informe', + genericFailureMessage: 'Se ha producido un error al actualizar el campo del informe. Por favor, inténtalo de nuevo.', }, tags: { tagName: 'Nombre de etiqueta', @@ -2613,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 = @@ -2735,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', @@ -2878,6 +3046,8 @@ export default { editor: { nameInputLabel: 'Nombre', descriptionInputLabel: 'Descripción', + typeInputLabel: 'Tipo', + initialValueInputLabel: 'Valor inicial', nameInputHelpText: 'Este es el nombre que verás en tu espacio de trabajo.', nameIsRequiredError: 'Debes definir un nombre para tu espacio de trabajo.', currencyInputLabel: 'Moneda por defecto', diff --git a/src/languages/types.ts b/src/languages/types.ts index 7ec56760c2f1..78a711fe8282 100644 --- a/src/languages/types.ts +++ b/src/languages/types.ts @@ -298,6 +298,8 @@ type DistanceRateOperationsParams = {count: number}; type ReimbursementRateParams = {unit: Unit}; +type ConfirmHoldExpenseParams = {transactionCount: number}; + type ChangeFieldParams = {oldValue?: string; newValue: string; fieldName: string}; type ChangePolicyParams = {fromPolicy: string; toPolicy: string}; @@ -350,6 +352,7 @@ export type { BeginningOfChatHistoryDomainRoomPartOneParams, CanceledRequestParams, CharacterLimitParams, + ConfirmHoldExpenseParams, ConfirmThatParams, DateShouldBeAfterParams, DateShouldBeBeforeParams, 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/CreateWorkspaceReportFieldParams.ts b/src/libs/API/parameters/CreateWorkspaceReportFieldParams.ts new file mode 100644 index 000000000000..13844a279905 --- /dev/null +++ b/src/libs/API/parameters/CreateWorkspaceReportFieldParams.ts @@ -0,0 +1,6 @@ +type CreateWorkspaceReportFieldParams = { + policyID: string; + reportFields: string; +}; + +export default CreateWorkspaceReportFieldParams; diff --git a/src/libs/API/parameters/PayInvoiceParams.ts b/src/libs/API/parameters/PayInvoiceParams.ts index 4c6633749adb..a6b9746d87bc 100644 --- a/src/libs/API/parameters/PayInvoiceParams.ts +++ b/src/libs/API/parameters/PayInvoiceParams.ts @@ -4,6 +4,7 @@ type PayInvoiceParams = { reportID: string; reportActionID: string; paymentMethodType: PaymentMethodType; + payAsBusiness: boolean; }; export default PayInvoiceParams; diff --git a/src/libs/API/parameters/SyncPolicyToNetSuiteParams.ts b/src/libs/API/parameters/SyncPolicyToNetSuiteParams.ts new file mode 100644 index 000000000000..9227f40997ff --- /dev/null +++ b/src/libs/API/parameters/SyncPolicyToNetSuiteParams.ts @@ -0,0 +1,6 @@ +type SyncPolicyToNetSuiteParams = { + policyID: string; + idempotencyKey: string; +}; + +export default SyncPolicyToNetSuiteParams; diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts index 2f203a4cfd9a..848af7e34634 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -14,6 +14,7 @@ export type {default as ConnectBankAccountParams} from './ConnectBankAccountPara export type {default as ConnectPolicyToAccountingIntegrationParams} from './ConnectPolicyToAccountingIntegrationParams'; export type {default as SyncPolicyToQuickbooksOnlineParams} from './SyncPolicyToQuickbooksOnlineParams'; export type {default as SyncPolicyToXeroParams} from './SyncPolicyToXeroParams'; +export type {default as SyncPolicyToNetSuiteParams} from './SyncPolicyToNetSuiteParams'; export type {default as DeleteContactMethodParams} from './DeleteContactMethodParams'; export type {default as DeletePaymentBankAccountParams} from './DeletePaymentBankAccountParams'; export type {default as DeletePaymentCardParams} from './DeletePaymentCardParams'; @@ -237,6 +238,8 @@ export type {default as DeleteMoneyRequestOnSearchParams} from './DeleteMoneyReq 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 ConnectPolicyToNetSuiteParams} from './ConnectPolicyToNetSuiteParams'; +export type {default as CreateWorkspaceReportFieldParams} from './CreateWorkspaceReportFieldParams'; export type {default as OpenPolicyExpensifyCardsPageParams} from './OpenPolicyExpensifyCardsPageParams'; export type {default as RequestExpensifyCardLimitIncreaseParams} from './RequestExpensifyCardLimitIncreaseParams'; export type {default as UpdateNetSuiteGenericTypeParams} from './UpdateNetSuiteGenericTypeParams'; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index c5d5f1ad1e6e..1bf1efc8af24 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', @@ -234,7 +234,14 @@ const WRITE_COMMANDS = { UNHOLD_MONEY_REQUEST_ON_SEARCH: 'UnholdMoneyRequestOnSearch', REQUEST_REFUND: 'User_RefundPurchase', 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', @@ -250,8 +257,16 @@ 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; type WriteCommand = ValueOf; @@ -456,6 +471,7 @@ type WriteCommandParameters = { [WRITE_COMMANDS.SET_POLICY_DISTANCE_RATES_DEFAULT_CATEGORY]: Parameters.SetPolicyDistanceRatesDefaultCategoryParams; [WRITE_COMMANDS.ENABLE_DISTANCE_REQUEST_TAX]: Parameters.SetPolicyDistanceRatesDefaultCategoryParams; [WRITE_COMMANDS.REQUEST_EXPENSIFY_CARD_LIMIT_INCREASE]: Parameters.RequestExpensifyCardLimitIncreaseParams; + [WRITE_COMMANDS.CLEAR_OUTSTANDING_BALANCE]: null; [WRITE_COMMANDS.UPDATE_POLICY_CONNECTION_CONFIG]: Parameters.UpdatePolicyConnectionConfigParams; [WRITE_COMMANDS.UPDATE_MANY_POLICY_CONNECTION_CONFIGS]: Parameters.UpdateManyPolicyConnectionConfigurationsParams; @@ -478,7 +494,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; @@ -492,7 +507,18 @@ type WriteCommandParameters = { // 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>; @@ -508,6 +534,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 = { @@ -515,6 +547,7 @@ const READ_COMMANDS = { CONNECT_POLICY_TO_XERO: 'ConnectPolicyToXero', SYNC_POLICY_TO_QUICKBOOKS_ONLINE: 'SyncPolicyToQuickbooksOnline', SYNC_POLICY_TO_XERO: 'SyncPolicyToXero', + SYNC_POLICY_TO_NETSUITE: 'SyncPolicyToNetSuite', OPEN_REIMBURSEMENT_ACCOUNT_PAGE: 'OpenReimbursementAccountPage', OPEN_WORKSPACE_VIEW: 'OpenWorkspaceView', GET_MAPBOX_ACCESS_TOKEN: 'GetMapboxAccessToken', @@ -564,6 +597,7 @@ type ReadCommandParameters = { [READ_COMMANDS.CONNECT_POLICY_TO_XERO]: Parameters.ConnectPolicyToAccountingIntegrationParams; [READ_COMMANDS.SYNC_POLICY_TO_QUICKBOOKS_ONLINE]: Parameters.SyncPolicyToQuickbooksOnlineParams; [READ_COMMANDS.SYNC_POLICY_TO_XERO]: Parameters.SyncPolicyToXeroParams; + [READ_COMMANDS.SYNC_POLICY_TO_NETSUITE]: Parameters.SyncPolicyToNetSuiteParams; [READ_COMMANDS.OPEN_REIMBURSEMENT_ACCOUNT_PAGE]: Parameters.OpenReimbursementAccountPageParams; [READ_COMMANDS.OPEN_WORKSPACE_VIEW]: Parameters.OpenWorkspaceViewParams; [READ_COMMANDS.GET_MAPBOX_ACCESS_TOKEN]: null; @@ -639,4 +673,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/DateUtils.ts b/src/libs/DateUtils.ts index f538e5e719e2..8a8888902e92 100644 --- a/src/libs/DateUtils.ts +++ b/src/libs/DateUtils.ts @@ -806,6 +806,19 @@ function doesDateBelongToAPastYear(date: string): boolean { return transactionYear !== new Date().getFullYear(); } +/** + * Returns a boolean value indicating whether the card has expired. + * @param expiryMonth month when card expires (starts from 1 so can be any number between 1 and 12) + * @param expiryYear year when card expires + */ + +function isCardExpired(expiryMonth: number, expiryYear: number): boolean { + const currentYear = new Date().getFullYear(); + const currentMonth = new Date().getMonth() + 1; + + return expiryYear < currentYear || (expiryYear === currentYear && expiryMonth < currentMonth); +} + const DateUtils = { isDate, formatToDayOfWeek, @@ -850,6 +863,7 @@ const DateUtils = { getFormattedReservationRangeDate, getFormattedTransportDate, doesDateBelongToAPastYear, + isCardExpired, }; export default DateUtils; 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/common.ts b/src/libs/ExportOnyxState/common.ts new file mode 100644 index 000000000000..ac2adf010eca --- /dev/null +++ b/src/libs/ExportOnyxState/common.ts @@ -0,0 +1,34 @@ +import {Str} from 'expensify-common'; +import ONYXKEYS from '@src/ONYXKEYS'; + +const maskFragileData = (data: Record, parentKey?: string): Record => { + const maskedData: Record = {}; + + if (!data) { + return maskedData; + } + + Object.keys(data).forEach((key) => { + if (!Object.prototype.hasOwnProperty.call(data, key)) { + return; + } + + const value = data[key]; + + if (typeof value === 'string' && (Str.isValidEmail(value) || key === 'authToken' || key === 'encryptedAuthToken')) { + maskedData[key] = '***'; + } else if (parentKey && parentKey.includes(ONYXKEYS.COLLECTION.REPORT_ACTIONS) && (key === 'text' || key === 'html')) { + maskedData[key] = '***'; + } else if (typeof value === 'object') { + maskedData[key] = maskFragileData(value as Record, key.includes(ONYXKEYS.COLLECTION.REPORT_ACTIONS) ? key : parentKey); + } else { + maskedData[key] = value; + } + }); + + return maskedData; +}; + +export default { + maskFragileData, +}; diff --git a/src/libs/ExportOnyxState/index.native.ts b/src/libs/ExportOnyxState/index.native.ts new file mode 100644 index 000000000000..bc32b29bc2ab --- /dev/null +++ b/src/libs/ExportOnyxState/index.native.ts @@ -0,0 +1,42 @@ +import RNFS from 'react-native-fs'; +import {open} from 'react-native-quick-sqlite'; +import Share from 'react-native-share'; +import CONST from '@src/CONST'; +import common from './common'; + +const readFromOnyxDatabase = () => + new Promise((resolve) => { + const db = open({name: CONST.DEFAULT_DB_NAME}); + const query = `SELECT * FROM ${CONST.DEFAULT_TABLE_NAME}`; + + 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) as unknown})); + + resolve(result); + }); + }); + +const shareAsFile = (value: string) => { + try { + // Define new filename and path for the app info file + const infoFileName = CONST.DEFAULT_ONYX_DUMP_FILE_NAME; + const infoFilePath = `${RNFS.DocumentDirectoryPath}/${infoFileName}`; + const actualInfoFile = `file://${infoFilePath}`; + + RNFS.writeFile(infoFilePath, value, 'utf8').then(() => { + Share.open({ + url: actualInfoFile, + failOnCancel: false, + }); + }); + } catch (error) { + console.error('Error renaming and sharing file:', error); + } +}; + +export default { + maskFragileData: common.maskFragileData, + readFromOnyxDatabase, + shareAsFile, +}; diff --git a/src/libs/ExportOnyxState/index.ts b/src/libs/ExportOnyxState/index.ts new file mode 100644 index 000000000000..148548ce5d1c --- /dev/null +++ b/src/libs/ExportOnyxState/index.ts @@ -0,0 +1,50 @@ +import CONST from '@src/CONST'; +import common from './common'; + +const readFromOnyxDatabase = () => + new Promise>((resolve) => { + let db: IDBDatabase; + const openRequest = indexedDB.open(CONST.DEFAULT_DB_NAME, 1); + openRequest.onsuccess = () => { + db = openRequest.result; + const transaction = db.transaction(CONST.DEFAULT_TABLE_NAME); + const objectStore = transaction.objectStore(CONST.DEFAULT_TABLE_NAME); + const cursor = objectStore.openCursor(); + + const queryResult: Record = {}; + + cursor.onerror = () => { + console.error('Error reading cursor'); + }; + + cursor.onsuccess = (event) => { + const {result} = event.target as IDBRequest; + if (result) { + queryResult[result.primaryKey as string] = result.value; + result.continue(); + } else { + // no results mean the cursor has reached the end of the data + resolve(queryResult); + } + }; + }; + }); + +const shareAsFile = (value: string) => { + const element = document.createElement('a'); + element.setAttribute('href', `data:text/plain;charset=utf-8,${encodeURIComponent(value)}`); + element.setAttribute('download', CONST.DEFAULT_ONYX_DUMP_FILE_NAME); + + element.style.display = 'none'; + document.body.appendChild(element); + + element.click(); + + document.body.removeChild(element); +}; + +export default { + maskFragileData: common.maskFragileData, + readFromOnyxDatabase, + shareAsFile, +}; 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 e0fb17f882d3..8a1c22233b95 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, @@ -320,7 +320,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 +351,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]: () => @@ -361,6 +369,11 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../../pages/settings/Subscription/PaymentCard/ChangeBillingCurrency').default, [SCREENS.SETTINGS.SUBSCRIPTION.ADD_PAYMENT_CARD]: () => require('../../../../pages/settings/Subscription/PaymentCard').default, [SCREENS.SETTINGS.ADD_PAYMENT_CARD_CHANGE_CURRENCY]: () => require('../../../../pages/settings/PaymentCard/ChangeCurrency').default, + [SCREENS.WORKSPACE.REPORT_FIELDS_CREATE]: () => require('../../../../pages/workspace/reportFields/WorkspaceCreateReportFieldPage').default, + [SCREENS.WORKSPACE.REPORT_FIELDS_LIST_VALUES]: () => require('../../../../pages/workspace/reportFields/ReportFieldListValuesPage').default, + [SCREENS.WORKSPACE.REPORT_FIELDS_ADD_VALUE]: () => require('../../../../pages/workspace/reportFields/ReportFieldAddListValuePage').default, + [SCREENS.WORKSPACE.REPORT_FIELDS_VALUE_SETTINGS]: () => require('../../../../pages/workspace/reportFields/ValueSettingsPage').default, + [SCREENS.WORKSPACE.REPORT_FIELDS_EDIT_VALUE]: () => require('../../../../pages/workspace/reportFields/ReportFieldEditValuePage').default, }); const EnablePaymentsStackNavigator = createModalStackNavigator({ 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/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.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 +74,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, @@ -102,7 +107,13 @@ const FULL_SCREEN_TO_RHP_MAPPING: Partial> = { SCREENS.WORKSPACE.DISTANCE_RATE_TAX_RATE_EDIT, SCREENS.WORKSPACE.DISTANCE_RATE_DETAILS, ], - [SCREENS.WORKSPACE.REPORT_FIELDS]: [], + [SCREENS.WORKSPACE.REPORT_FIELDS]: [ + SCREENS.WORKSPACE.REPORT_FIELDS_CREATE, + SCREENS.WORKSPACE.REPORT_FIELDS_LIST_VALUES, + SCREENS.WORKSPACE.REPORT_FIELDS_ADD_VALUE, + SCREENS.WORKSPACE.REPORT_FIELDS_VALUE_SETTINGS, + SCREENS.WORKSPACE.REPORT_FIELDS_EDIT_VALUE, + ], [SCREENS.WORKSPACE.EXPENSIFY_CARD]: [], }; diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts index 01b467fb53de..e6f230432846 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}, @@ -525,6 +532,21 @@ const config: LinkingOptions['config'] = { [SCREENS.WORKSPACE.TAXES_SETTINGS_WORKSPACE_CURRENCY_DEFAULT]: { path: ROUTES.WORKSPACE_TAXES_SETTINGS_WORKSPACE_CURRENCY_DEFAULT.route, }, + [SCREENS.WORKSPACE.REPORT_FIELDS_CREATE]: { + path: ROUTES.WORKSPACE_CREATE_REPORT_FIELD.route, + }, + [SCREENS.WORKSPACE.REPORT_FIELDS_LIST_VALUES]: { + path: ROUTES.WORKSPACE_REPORT_FIELD_LIST_VALUES.route, + }, + [SCREENS.WORKSPACE.REPORT_FIELDS_ADD_VALUE]: { + path: ROUTES.WORKSPACE_REPORT_FIELD_ADD_VALUE.route, + }, + [SCREENS.WORKSPACE.REPORT_FIELDS_VALUE_SETTINGS]: { + path: ROUTES.WORKSPACE_REPORT_FIELD_VALUE_SETTINGS.route, + }, + [SCREENS.WORKSPACE.REPORT_FIELDS_EDIT_VALUE]: { + path: ROUTES.WORKSPACE_REPORT_FIELD_EDIT_VALUE.route, + }, [SCREENS.REIMBURSEMENT_ACCOUNT]: { path: ROUTES.BANK_ACCOUNT_WITH_STEP_TO_OPEN.route, exact: true, 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 0a2809d97208..a59ff9de0b08 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'; @@ -268,6 +268,23 @@ type SettingsNavigatorParamList = { [SCREENS.WORKSPACE.TAXES_SETTINGS_WORKSPACE_CURRENCY_DEFAULT]: { policyID: string; }; + [SCREENS.WORKSPACE.REPORT_FIELDS_CREATE]: { + policyID: string; + }; + [SCREENS.WORKSPACE.REPORT_FIELDS_LIST_VALUES]: { + policyID: string; + }; + [SCREENS.WORKSPACE.REPORT_FIELDS_ADD_VALUE]: { + policyID: string; + }; + [SCREENS.WORKSPACE.REPORT_FIELDS_VALUE_SETTINGS]: { + policyID: string; + valueIndex: number; + }; + [SCREENS.WORKSPACE.REPORT_FIELDS_EDIT_VALUE]: { + policyID: string; + valueIndex: number; + }; [SCREENS.WORKSPACE.MEMBER_DETAILS]: { policyID: string; accountID: string; @@ -399,9 +416,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; }; @@ -446,6 +476,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; }; 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..7c495e625e10 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, 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/PersonalDetailsUtils.ts b/src/libs/PersonalDetailsUtils.ts index 8ba468e87ed0..8bdf0cb1d5fe 100644 --- a/src/libs/PersonalDetailsUtils.ts +++ b/src/libs/PersonalDetailsUtils.ts @@ -157,6 +157,7 @@ function getPersonalDetailsOnyxDataForOptimisticUsers(newLogins: string[], newAc login, accountID, displayName: LocalePhoneNumber.formatPhoneNumber(login), + isOptimisticPersonalDetail: true, }; /** 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 65aaf4c9de0a..2dd579cd3569 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -350,29 +350,6 @@ 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 a sorted and filtered list of report actions from a report and it's associated child * transaction thread report in order to correctly display reportActions from both reports in the one-transaction report view. @@ -409,51 +386,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 +1442,6 @@ export { shouldReportActionBeVisible, shouldHideNewMarker, shouldReportActionBeVisibleAsLastAction, - getContinuousReportActionChain, hasRequestFromCurrentAccount, getFirstVisibleReportActionID, isMemberChangeAction, diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 8dfc05e911e5..de7be3240850 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -902,11 +902,20 @@ 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; } @@ -1111,12 +1120,13 @@ function isProcessingReport(report: OnyxEntry): boolean { * and personal detail of participant is optimistic data */ function shouldDisableDetailPage(report: OnyxEntry): boolean { - const participantAccountIDs = Object.keys(report?.participants ?? {}).map(Number); - if (isChatRoom(report) || isPolicyExpenseChat(report) || isChatThread(report) || isTaskReport(report)) { return false; } - if (participantAccountIDs.length === 1) { + if (isOneOnOneChat(report)) { + const participantAccountIDs = Object.keys(report?.participants ?? {}) + .map(Number) + .filter((accountID) => accountID !== currentUserAccountID); return isOptimisticPersonalDetail(participantAccountIDs[0]); } return false; @@ -1444,6 +1454,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 */ @@ -1543,6 +1569,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; } @@ -1887,7 +1918,6 @@ 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; }); } @@ -1977,7 +2007,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, @@ -2028,9 +2058,15 @@ function getIcons( if (report?.invoiceReceiver?.type === CONST.REPORT.INVOICE_RECEIVER_TYPE.INDIVIDUAL) { icons.push(...getIconsForParticipants([report?.invoiceReceiver.accountID], personalDetails)); } else { - const receiverPolicy = getPolicy(report?.invoiceReceiver?.policyID); + const receiverPolicyID = report?.invoiceReceiver?.policyID; + const receiverPolicy = getPolicy(receiverPolicyID); if (!isEmptyObject(receiverPolicy)) { - icons.push(getWorkspaceIcon(report, receiverPolicy)); + icons.push({ + source: receiverPolicy?.avatarURL ?? getDefaultWorkspaceAvatar(receiverPolicy.name), + type: CONST.ICON_TYPE_WORKSPACE, + name: receiverPolicy.name, + id: receiverPolicyID, + }); } } } @@ -2102,10 +2138,16 @@ function getIcons( return icons; } - const receiverPolicy = getPolicy(invoiceRoomReport?.invoiceReceiver?.policyID); + const receiverPolicyID = invoiceRoomReport?.invoiceReceiver?.policyID; + const receiverPolicy = getPolicy(receiverPolicyID); if (!isEmptyObject(receiverPolicy)) { - icons.push(getWorkspaceIcon(invoiceRoomReport, receiverPolicy)); + icons.push({ + source: receiverPolicy?.avatarURL ?? getDefaultWorkspaceAvatar(receiverPolicy.name), + type: CONST.ICON_TYPE_WORKSPACE, + name: receiverPolicy.name, + id: receiverPolicyID, + }); } return icons; @@ -2570,7 +2612,16 @@ function getMoneyRequestReportName(report: OnyxEntry, policy?: OnyxEntry const moneyRequestTotal = getMoneyRequestSpendBreakdown(report).totalDisplaySpend; const formattedAmount = CurrencyUtils.convertToDisplayString(moneyRequestTotal, report?.currency); - let payerOrApproverName = isExpenseReport(report) ? getPolicyName(report, false, policy) : getDisplayNameForParticipant(report?.managerID) ?? ''; + 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) ?? ''; + } + const payerPaidAmountMessage = Localize.translateLocal('iou.payerPaidAmount', { payer: payerOrApproverName, amount: formattedAmount, @@ -3978,77 +4029,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}); } /** @@ -4062,11 +4047,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) @@ -4103,6 +4083,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; } @@ -4402,7 +4385,6 @@ function buildOptimisticActionableTrackExpenseWhisper(iouAction: OptimisticIOURe type: 'TEXT', }, ], - previousReportActionID: iouAction?.reportActionID, reportActionID, shouldShow: true, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, @@ -5569,6 +5551,7 @@ function getChatByParticipants(newParticipantList: number[], reports: OnyxCollec isChatThread(report) || isTaskReport(report) || isMoneyRequestReport(report) || + isInvoiceReport(report) || isChatRoom(report) || isPolicyExpenseChat(report) || (isGroupChat(report) && !shouldIncludeGroupChats) @@ -6888,6 +6871,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 */ @@ -7164,6 +7153,7 @@ export { getGroupChatName, getIOUReportActionDisplayMessage, getIOUReportActionMessage, + getIOUSubmittedMessage, getIcons, getIconsForParticipants, getIndicatedMissingPaymentMethod, @@ -7347,10 +7337,13 @@ 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 5a7f514a7196..7dd8c89a398d 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,8 @@ function getReportSections(data: OnyxTypes.SearchResults['data'], metadata: Onyx reportIDToTransactions[reportKey] = { ...value, + from: data.personalDetailsList?.[value.accountID], + to: data.personalDetailsList?.[value.managerID], transactions, }; } else if (key.startsWith(ONYXKEYS.COLLECTION.TRANSACTION)) { @@ -199,7 +202,7 @@ function getReportSections(data: OnyxTypes.SearchResults['data'], metadata: Onyx if (reportIDToTransactions[reportKey]?.transactions) { reportIDToTransactions[reportKey].transactions.push(transaction); } else { - reportIDToTransactions[reportKey] = {transactions: [transaction]}; + reportIDToTransactions[reportKey].transactions = [transaction]; } } } @@ -217,7 +220,7 @@ const searchTypeToItemMap: SearchTypeToItemMap = { listItem: ReportListItem, getSections: getReportSections, // sorting for ReportItems not yet implemented - getSortedSections: (data) => data, + getSortedSections: getSortedReportData, }, }; @@ -278,10 +281,39 @@ function getSortedTransactionData(data: TransactionListItemType[], sortBy?: Sear }); } +function getSortedReportData(data: ReportListItemType[]) { + return data.sort((a, b) => { + const aValue = a?.created; + const bValue = b?.created; + + if (aValue === undefined || bValue === undefined) { + return 0; + } + + return bValue.toLowerCase().localeCompare(aValue); + }); +} + function getSearchParams() { const topmostCentralPaneRoute = getTopmostCentralPaneRoute(navigationRef.getRootState() as State); 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 8569a3f03128..9a4d1d1c5d28 100644 --- a/src/libs/SubscriptionUtils.ts +++ b/src/libs/SubscriptionUtils.ts @@ -81,6 +81,7 @@ Onyx.connect({ let retryBillingSuccessful: OnyxEntry; Onyx.connect({ key: ONYXKEYS.SUBSCRIPTION_RETRY_BILLING_STATUS_SUCCESSFUL, + initWithStoredValues: false, callback: (value) => { if (value === undefined) { return; @@ -350,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..c8bfa316ca15 100644 --- a/src/libs/TransactionUtils.ts +++ b/src/libs/TransactionUtils.ts @@ -387,6 +387,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. */ @@ -878,6 +885,7 @@ export { isCustomUnitRateIDForP2P, getRateID, getTransaction, + getReimbursable, }; export type {TransactionChanges}; diff --git a/src/libs/TripReservationUtils.ts b/src/libs/TripReservationUtils.ts index ead786b8eafd..e937979ae7b9 100644 --- a/src/libs/TripReservationUtils.ts +++ b/src/libs/TripReservationUtils.ts @@ -1,3 +1,4 @@ +import type {EReceiptColorName} from '@styles/utils/types'; import * as Expensicons from '@src/components/Icon/Expensicons'; import CONST from '@src/CONST'; import type {Reservation, ReservationType} from '@src/types/onyx/Transaction'; @@ -24,4 +25,26 @@ function getReservationsFromTripTransactions(transactions: Transaction[]): Reser .flat(); } -export {getTripReservationIcon, getReservationsFromTripTransactions}; +type TripEReceiptData = { + /** Icon asset associated with the type of trip reservation */ + tripIcon?: IconAsset; + + /** EReceipt background color associated with the type of trip reservation */ + tripBGColor?: EReceiptColorName; +}; + +function getTripEReceiptData(transaction?: Transaction): TripEReceiptData { + const reservationType = transaction ? transaction.receipt?.reservationList?.[0]?.type : ''; + + switch (reservationType) { + case CONST.RESERVATION_TYPE.FLIGHT: + case CONST.RESERVATION_TYPE.CAR: + return {tripIcon: Expensicons.Plane, tripBGColor: CONST.ERECEIPT_COLORS.PINK}; + case CONST.RESERVATION_TYPE.HOTEL: + return {tripIcon: Expensicons.Bed, tripBGColor: CONST.ERECEIPT_COLORS.YELLOW}; + default: + return {}; + } +} + +export {getTripReservationIcon, getReservationsFromTripTransactions, getTripEReceiptData}; diff --git a/src/libs/WorkspaceReportFieldsUtils.ts b/src/libs/WorkspaceReportFieldsUtils.ts new file mode 100644 index 000000000000..0cc3cae24a23 --- /dev/null +++ b/src/libs/WorkspaceReportFieldsUtils.ts @@ -0,0 +1,70 @@ +import type {FormInputErrors} from '@components/Form/types'; +import CONST from '@src/CONST'; +import type {TranslationPaths} from '@src/languages/types'; +import type ONYXKEYS from '@src/ONYXKEYS'; +import type {InputID} from '@src/types/form/WorkspaceReportFieldsForm'; +import type {PolicyReportFieldType} from '@src/types/onyx/Policy'; +import * as ErrorUtils from './ErrorUtils'; +import * as Localize from './Localize'; +import * as ValidationUtils from './ValidationUtils'; + +/** + * Gets the translation key for the report field type. + */ +function getReportFieldTypeTranslationKey(reportFieldType: PolicyReportFieldType): TranslationPaths { + const typeTranslationKeysStrategy: Record = { + [CONST.REPORT_FIELD_TYPES.TEXT]: 'workspace.reportFields.textType', + [CONST.REPORT_FIELD_TYPES.DATE]: 'workspace.reportFields.dateType', + [CONST.REPORT_FIELD_TYPES.LIST]: 'workspace.reportFields.dropdownType', + }; + + return typeTranslationKeysStrategy[reportFieldType]; +} + +/** + * Gets the translation key for the alternative text for the report field. + */ +function getReportFieldAlternativeTextTranslationKey(reportFieldType: PolicyReportFieldType): TranslationPaths { + const typeTranslationKeysStrategy: Record = { + [CONST.REPORT_FIELD_TYPES.TEXT]: 'workspace.reportFields.textAlternateText', + [CONST.REPORT_FIELD_TYPES.DATE]: 'workspace.reportFields.dateAlternateText', + [CONST.REPORT_FIELD_TYPES.LIST]: 'workspace.reportFields.dropdownAlternateText', + }; + + return typeTranslationKeysStrategy[reportFieldType]; +} + +/** + * Validates the list value name. + */ +function validateReportFieldListValueName( + valueName: string, + priorValueName: string, + listValues: string[], + inputID: InputID, +): FormInputErrors { + const errors: FormInputErrors = {}; + + if (!ValidationUtils.isRequiredFulfilled(valueName)) { + errors[inputID] = Localize.translateLocal('workspace.reportFields.listValueRequiredError'); + } else if (priorValueName !== valueName && listValues.some((currentValueName) => currentValueName === valueName)) { + errors[inputID] = Localize.translateLocal('workspace.reportFields.existingListValueError'); + } else if ([...valueName].length > CONST.WORKSPACE_REPORT_FIELD_POLICY_MAX_LENGTH) { + // Uses the spread syntax to count the number of Unicode code points instead of the number of UTF-16 code units. + ErrorUtils.addErrorMessage( + errors, + inputID, + Localize.translateLocal('common.error.characterLimitExceedCounter', {length: [...valueName].length, limit: CONST.WORKSPACE_REPORT_FIELD_POLICY_MAX_LENGTH}), + ); + } + + return errors; +} +/** + * Generates a field ID based on the field name. + */ +function generateFieldID(name: string) { + return `field_id_${name.replace(CONST.REGEX.ANY_SPACE, '_').toUpperCase()}`; +} + +export {getReportFieldTypeTranslationKey, getReportFieldAlternativeTextTranslationKey, validateReportFieldListValueName, generateFieldID}; 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 48c70021cacc..42381d9008a7 100644 --- a/src/libs/actions/IOU.ts +++ b/src/libs/actions/IOU.ts @@ -291,6 +291,12 @@ 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 */ @@ -5938,13 +5944,22 @@ function getSendMoneyParams( } function getPayMoneyRequestParams( - chatReport: OnyxTypes.Report, + initialChatReport: 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) { @@ -5977,19 +5992,27 @@ 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: { - ...chatReport, - lastReadTime: DateUtils.getDBTime(), - lastVisibleActionCreated: optimisticIOUReportAction.created, - hasOutstandingChildRequest: false, - iouReportID: null, - lastMessageText: ReportActionsUtils.getReportActionText(optimisticIOUReportAction), - lastMessageHtml: ReportActionsUtils.getReportActionHtml(optimisticIOUReportAction), - }, + value: optimisticChatReport, }, { onyxMethod: Onyx.METHOD.MERGE, @@ -6611,19 +6634,20 @@ function payMoneyRequest(paymentType: PaymentMethodType, chatReport: OnyxTypes.R Navigation.dismissModalWithReport(chatReport); } -function payInvoice(paymentMethodType: PaymentMethodType, chatReport: OnyxTypes.Report, invoiceReport: OnyxTypes.Report) { +function payInvoice(paymentMethodType: PaymentMethodType, chatReport: OnyxTypes.Report, invoiceReport: OnyxTypes.Report, payAsBusiness = false) { const recipient = {accountID: invoiceReport.ownerAccountID}; const { optimisticData, successData, failureData, params: {reportActionID}, - } = getPayMoneyRequestParams(chatReport, invoiceReport, recipient, paymentMethodType, true); + } = getPayMoneyRequestParams(chatReport, invoiceReport, recipient, paymentMethodType, true, payAsBusiness); 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 cd1acb564e22..ca61fceaaa78 100644 --- a/src/libs/actions/Policy/Policy.ts +++ b/src/libs/actions/Policy/Policy.ts @@ -187,7 +187,7 @@ function getPolicy(policyID: string | undefined): OnyxEntry { */ function getPrimaryPolicy(activePolicyID?: OnyxEntry): Policy | undefined { const activeAdminWorkspaces = PolicyUtils.getActiveAdminWorkspaces(allPolicies); - const primaryPolicy: Policy | null | undefined = allPolicies?.[activePolicyID ?? '-1']; + const primaryPolicy: Policy | null | undefined = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${activePolicyID}`]; return primaryPolicy ?? activeAdminWorkspaces[0]; } @@ -533,6 +533,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); @@ -3056,6 +3060,7 @@ export { clearQBOErrorField, clearXeroErrorField, clearNetSuiteErrorField, + clearNetSuiteAutoSyncErrorField, clearWorkspaceReimbursementErrors, setWorkspaceCurrencyDefault, setForeignCurrencyDefault, diff --git a/src/libs/actions/Policy/ReportFields.ts b/src/libs/actions/Policy/ReportFields.ts new file mode 100644 index 000000000000..220432cbc3c6 --- /dev/null +++ b/src/libs/actions/Policy/ReportFields.ts @@ -0,0 +1,209 @@ +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 {WRITE_COMMANDS} from '@libs/API/types'; +import * as ErrorUtils from '@libs/ErrorUtils'; +import * as ReportUtils from '@libs/ReportUtils'; +import {generateFieldID} from '@libs/WorkspaceReportFieldsUtils'; +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 {OnyxData} from '@src/types/onyx/Request'; + +let listValues: string[]; +let disabledListValues: boolean[]; +Onyx.connect({ + key: ONYXKEYS.FORMS.WORKSPACE_REPORT_FIELDS_FORM_DRAFT, + callback: (value) => { + if (!value) { + return; + } + + listValues = value[INPUT_IDS.LIST_VALUES] ?? []; + disabledListValues = value[INPUT_IDS.DISABLED_LIST_VALUES] ?? []; + }, +}); + +const allPolicies: OnyxCollection = {}; +Onyx.connect({ + key: ONYXKEYS.COLLECTION.POLICY, + callback: (value, key) => { + 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. + // More info: https://github.com/Expensify/App/issues/14260 + const policyID = key.replace(ONYXKEYS.COLLECTION.POLICY, ''); + const policyReports = ReportUtils.getAllPolicyReports(policyID); + const cleanUpMergeQueries: Record<`${typeof ONYXKEYS.COLLECTION.REPORT}${string}`, NullishDeep> = {}; + const cleanUpSetQueries: Record<`${typeof ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${string}` | `${typeof ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${string}`, null> = {}; + policyReports.forEach((policyReport) => { + if (!policyReport) { + return; + } + const {reportID} = policyReport; + cleanUpSetQueries[`${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${reportID}`] = null; + cleanUpSetQueries[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${reportID}`] = null; + }); + Onyx.mergeCollection(ONYXKEYS.COLLECTION.REPORT, cleanUpMergeQueries); + Onyx.multiSet(cleanUpSetQueries); + delete allPolicies[key]; + return; + } + + allPolicies[key] = value; + }, +}); + +/** + * Sets the initial form values for the workspace report fields form. + */ +function setInitialCreateReportFieldsForm() { + Onyx.set(ONYXKEYS.FORMS.WORKSPACE_REPORT_FIELDS_FORM_DRAFT, { + [INPUT_IDS.INITIAL_VALUE]: '', + }); +} + +/** + * Creates a new list value in the workspace report fields form. + */ +function createReportFieldsListValue(valueName: string) { + Onyx.merge(ONYXKEYS.FORMS.WORKSPACE_REPORT_FIELDS_FORM_DRAFT, { + [INPUT_IDS.LIST_VALUES]: [...listValues, valueName], + [INPUT_IDS.DISABLED_LIST_VALUES]: [...disabledListValues, false], + }); +} + +/** + * Renames a list value in the workspace report fields form. + */ +function renameReportFieldsListValue(valueIndex: number, newValueName: string) { + const listValuesCopy = [...listValues]; + listValuesCopy[valueIndex] = newValueName; + + Onyx.merge(ONYXKEYS.FORMS.WORKSPACE_REPORT_FIELDS_FORM_DRAFT, { + [INPUT_IDS.LIST_VALUES]: listValuesCopy, + }); +} + +/** + * Sets the enabled state of a list value in the workspace report fields form. + */ +function setReportFieldsListValueEnabled(valueIndexes: number[], enabled: boolean) { + const disabledListValuesCopy = [...disabledListValues]; + + valueIndexes.forEach((valueIndex) => { + disabledListValuesCopy[valueIndex] = !enabled; + }); + + Onyx.merge(ONYXKEYS.FORMS.WORKSPACE_REPORT_FIELDS_FORM_DRAFT, { + [INPUT_IDS.DISABLED_LIST_VALUES]: disabledListValuesCopy, + }); +} + +/** + * Deletes a list value from the workspace report fields form. + */ +function deleteReportFieldsListValue(valueIndexes: number[]) { + const listValuesCopy = [...listValues]; + const disabledListValuesCopy = [...disabledListValues]; + + valueIndexes + .sort((a, b) => b - a) + .forEach((valueIndex) => { + listValuesCopy.splice(valueIndex, 1); + disabledListValuesCopy.splice(valueIndex, 1); + }); + + Onyx.merge(ONYXKEYS.FORMS.WORKSPACE_REPORT_FIELDS_FORM_DRAFT, { + [INPUT_IDS.LIST_VALUES]: listValuesCopy, + [INPUT_IDS.DISABLED_LIST_VALUES]: disabledListValuesCopy, + }); +} + +type CreateReportFieldArguments = Pick; + +/** + * Creates a new report field. + */ +function createReportField(policyID: string, {name, type, initialValue}: CreateReportFieldArguments) { + const previousFieldList = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]?.fieldList ?? {}; + const fieldID = generateFieldID(name); + const fieldKey = ReportUtils.getReportFieldKey(fieldID); + const newReportField: PolicyReportField = { + name, + type, + defaultValue: initialValue, + values: listValues, + disabledOptions: disabledListValues, + fieldID, + orderWeight: Object.keys(previousFieldList).length + 1, + deletable: false, + value: type === CONST.REPORT_FIELD_TYPES.LIST ? CONST.REPORT_FIELD_TYPES.LIST : null, + keys: [], + externalIDs: [], + isTax: false, + }; + const onyxData: OnyxData = { + optimisticData: [ + { + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + onyxMethod: Onyx.METHOD.MERGE, + value: { + fieldList: { + [fieldKey]: newReportField, + }, + pendingFields: { + [fieldKey]: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + }, + errorFields: null, + }, + }, + ], + successData: [ + { + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + onyxMethod: Onyx.METHOD.MERGE, + value: { + pendingFields: { + [fieldKey]: null, + }, + errorFields: null, + }, + }, + ], + failureData: [ + { + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + onyxMethod: Onyx.METHOD.MERGE, + value: { + fieldList: { + [fieldKey]: null, + }, + pendingFields: { + [fieldKey]: null, + }, + errorFields: { + [fieldKey]: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('workspace.reportFields.genericFailureMessage'), + }, + }, + }, + ], + }; + const parameters: CreateWorkspaceReportFieldParams = { + policyID, + reportFields: JSON.stringify([newReportField]), + }; + + API.write(WRITE_COMMANDS.CREATE_WORKSPACE_REPORT_FIELD, parameters, onyxData); +} + +export type {CreateReportFieldArguments}; + +export {setInitialCreateReportFieldsForm, createReportFieldsListValue, renameReportFieldsListValue, setReportFieldsListValueEnabled, deleteReportFieldsListValue, createReportField}; diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index 31e801deeea4..a1a52b64d49f 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, + }, + ); } /** @@ -3118,6 +3158,7 @@ function completeOnboarding( lastName: string; }, adminsChatReportID?: string, + onboardingPolicyID?: string, ) { const isAccountIDOdd = AccountUtils.isAccountIDOddNumber(currentUserAccountID ?? 0); const targetEmail = isAccountIDOdd ? CONST.EMAIL.NOTIFICATIONS : CONST.EMAIL.CONCIERGE; @@ -3161,6 +3202,7 @@ function completeOnboarding( typeof task.description === 'function' ? task.description({ adminsRoomLink: `${CONFIG.EXPENSIFY.NEW_EXPENSIFY_URL}${ROUTES.REPORT_WITH_ID.getRoute(adminsChatReportID ?? '-1')}`, + workspaceLink: `${CONFIG.EXPENSIFY.NEW_EXPENSIFY_URL}${ROUTES.WORKSPACE_INITIAL.getRoute(onboardingPolicyID ?? '-1')}`, }) : task.description; const currentTask = ReportUtils.buildOptimisticTaskReport( 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/Subscription.ts b/src/libs/actions/Subscription.ts index 19a3bf0c547e..beed2b1b2962 100644 --- a/src/libs/actions/Subscription.ts +++ b/src/libs/actions/Subscription.ts @@ -231,4 +231,60 @@ function clearUpdateSubscriptionSizeError() { }); } -export {openSubscriptionPage, updateSubscriptionAutoRenew, updateSubscriptionAddNewUsersAutomatically, updateSubscriptionSize, clearUpdateSubscriptionSizeError, updateSubscriptionType}; +function clearOutstandingBalance() { + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.SUBSCRIPTION_RETRY_BILLING_STATUS_PENDING, + value: true, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.SUBSCRIPTION_RETRY_BILLING_STATUS_PENDING, + value: false, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.SUBSCRIPTION_RETRY_BILLING_STATUS_SUCCESSFUL, + value: true, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.SUBSCRIPTION_RETRY_BILLING_STATUS_FAILED, + value: false, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.SUBSCRIPTION_RETRY_BILLING_STATUS_PENDING, + value: false, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.SUBSCRIPTION_RETRY_BILLING_STATUS_SUCCESSFUL, + value: false, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.SUBSCRIPTION_RETRY_BILLING_STATUS_FAILED, + value: true, + }, + ], + }; + + API.write(WRITE_COMMANDS.CLEAR_OUTSTANDING_BALANCE, null, onyxData); +} + +export { + openSubscriptionPage, + updateSubscriptionAutoRenew, + updateSubscriptionAddNewUsersAutomatically, + updateSubscriptionSize, + clearUpdateSubscriptionSizeError, + updateSubscriptionType, + clearOutstandingBalance, +}; 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/actions/connections/index.ts b/src/libs/actions/connections/index.ts index 872e82951834..fd6440c3a92c 100644 --- a/src/libs/actions/connections/index.ts +++ b/src/libs/actions/connections/index.ts @@ -132,6 +132,9 @@ function getSyncConnectionParameters(connectionName: PolicyConnectionName) { case CONST.POLICY.CONNECTIONS.NAME.XERO: { return {readCommand: READ_COMMANDS.SYNC_POLICY_TO_XERO, stageInProgress: CONST.POLICY.CONNECTIONS.SYNC_STAGE_NAME.STARTING_IMPORT_XERO}; } + case CONST.POLICY.CONNECTIONS.NAME.NETSUITE: { + return {readCommand: READ_COMMANDS.SYNC_POLICY_TO_NETSUITE, stageInProgress: CONST.POLICY.CONNECTIONS.SYNC_STAGE_NAME.NETSUITE_SYNC_CONNECTION}; + } default: return undefined; } 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/InviteReportParticipantsPage.tsx b/src/pages/InviteReportParticipantsPage.tsx index 78cb5dfcd991..91fdd903ec3a 100644 --- a/src/pages/InviteReportParticipantsPage.tsx +++ b/src/pages/InviteReportParticipantsPage.tsx @@ -197,7 +197,6 @@ function InviteReportParticipantsPage({betas, personalDetails, report, didScreen onSubmit={inviteUsers} containerStyles={[styles.flexReset, styles.flexGrow0, styles.flexShrink0, styles.flexBasisAuto]} enabledWhenOffline - disablePressOnEnter /> ), [selectedOptions.length, inviteUsers, translate, styles], diff --git a/src/pages/OnboardingPersonalDetails/BaseOnboardingPersonalDetails.tsx b/src/pages/OnboardingPersonalDetails/BaseOnboardingPersonalDetails.tsx index ba90def232d5..f5bd14ed7aa1 100644 --- a/src/pages/OnboardingPersonalDetails/BaseOnboardingPersonalDetails.tsx +++ b/src/pages/OnboardingPersonalDetails/BaseOnboardingPersonalDetails.tsx @@ -31,7 +31,13 @@ import ROUTES from '@src/ROUTES'; import INPUT_IDS from '@src/types/form/DisplayNameForm'; import type {BaseOnboardingPersonalDetailsOnyxProps, BaseOnboardingPersonalDetailsProps} from './types'; -function BaseOnboardingPersonalDetails({currentUserPersonalDetails, shouldUseNativeStyles, onboardingPurposeSelected, onboardingAdminsChatReportID}: BaseOnboardingPersonalDetailsProps) { +function BaseOnboardingPersonalDetails({ + currentUserPersonalDetails, + shouldUseNativeStyles, + onboardingPurposeSelected, + onboardingAdminsChatReportID, + onboardingPolicyID, +}: BaseOnboardingPersonalDetailsProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); const {isSmallScreenWidth} = useWindowDimensions(); @@ -61,6 +67,7 @@ function BaseOnboardingPersonalDetails({currentUserPersonalDetails, shouldUseNat lastName, }, onboardingAdminsChatReportID ?? undefined, + onboardingPolicyID, ); Welcome.setOnboardingAdminsChatReportID(); @@ -84,7 +91,7 @@ function BaseOnboardingPersonalDetails({currentUserPersonalDetails, shouldUseNat Navigation.navigate(ROUTES.WELCOME_VIDEO_ROOT); }, variables.welcomeVideoDelay); }, - [isSmallScreenWidth, onboardingPurposeSelected, onboardingAdminsChatReportID, accountID], + [onboardingPurposeSelected, onboardingAdminsChatReportID, onboardingPolicyID, isSmallScreenWidth, accountID], ); const validate = (values: FormOnyxValues<'onboardingPersonalDetailsForm'>) => { @@ -194,5 +201,8 @@ export default withCurrentUserPersonalDetails( onboardingAdminsChatReportID: { key: ONYXKEYS.ONBOARDING_ADMINS_CHAT_REPORT_ID, }, + onboardingPolicyID: { + key: ONYXKEYS.ONBOARDING_POLICY_ID, + }, })(BaseOnboardingPersonalDetails), ); diff --git a/src/pages/OnboardingPersonalDetails/types.ts b/src/pages/OnboardingPersonalDetails/types.ts index a89fe5ff8df7..ccd4d3a52254 100644 --- a/src/pages/OnboardingPersonalDetails/types.ts +++ b/src/pages/OnboardingPersonalDetails/types.ts @@ -10,6 +10,9 @@ type BaseOnboardingPersonalDetailsOnyxProps = { /** Saved onboarding admin chat report ID */ onboardingAdminsChatReportID: OnyxEntry; + + /** Saved onboarding policy ID */ + onboardingPolicyID: OnyxEntry; }; type BaseOnboardingPersonalDetailsProps = WithCurrentUserPersonalDetailsProps & 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/RoomInvitePage.tsx b/src/pages/RoomInvitePage.tsx index afa57755ad70..8a807655ae57 100644 --- a/src/pages/RoomInvitePage.tsx +++ b/src/pages/RoomInvitePage.tsx @@ -266,7 +266,6 @@ function RoomInvitePage({ onSubmit={inviteUsers} containerStyles={[styles.flexReset, styles.flexGrow0, styles.flexShrink0, styles.flexBasisAuto, styles.mb5, styles.ph5]} enabledWhenOffline - disablePressOnEnter isAlertVisible={false} /> diff --git a/src/pages/home/ReportScreen.tsx b/src/pages/home/ReportScreen.tsx index 3c2ae7bbc6e6..539b46aa5b06 100644 --- a/src/pages/home/ReportScreen.tsx +++ b/src/pages/home/ReportScreen.tsx @@ -35,6 +35,7 @@ import Timing from '@libs/actions/Timing'; import Log from '@libs/Log'; import Navigation from '@libs/Navigation/Navigation'; import clearReportNotifications from '@libs/Notification/clearReportNotifications'; +import PaginationUtils from '@libs/PaginationUtils'; import Performance from '@libs/Performance'; import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; @@ -71,6 +72,9 @@ type ReportScreenOnyxProps = { /** An array containing all report actions related to this report, sorted based on a date criterion */ sortedAllReportActions: OnyxTypes.ReportAction[]; + /** Pagination data for sortedAllReportActions */ + reportActionPages: OnyxEntry; + /** Additional report details */ reportNameValuePairs: OnyxEntry; @@ -120,6 +124,7 @@ function ReportScreen({ route, reportNameValuePairs, sortedAllReportActions, + reportActionPages, reportMetadata = { isLoadingInitialReportActions: true, isLoadingOlderReportActions: false, @@ -287,8 +292,8 @@ function ReportScreen({ if (!sortedAllReportActions.length) { return []; } - return ReportActionsUtils.getContinuousReportActionChain(sortedAllReportActions, reportActionIDFromRoute); - }, [reportActionIDFromRoute, sortedAllReportActions]); + return PaginationUtils.getContinuousChain(sortedAllReportActions, reportActionPages ?? [], (reportAction) => reportAction.reportActionID, reportActionIDFromRoute); + }, [reportActionIDFromRoute, sortedAllReportActions, reportActionPages]); // Define here because reportActions are recalculated before mount, allowing data to display faster than useEffect can trigger. // If we have cached reportActions, they will be shown immediately. @@ -406,7 +411,13 @@ function ReportScreen({ return reportIDFromRoute !== '' && !!report.reportID && !isTransitioning; }, [report, reportIDFromRoute]); - const isLoading = isLoadingApp ?? (!reportIDFromRoute || (!isSidebarLoaded && !isReportOpenInRHP) || PersonalDetailsUtils.isPersonalDetailsEmpty()); + /** + * Using logical OR operator because with nullish coalescing operator, when `isLoadingApp` is false, the right hand side of the operator + * is not evaluated. This causes issues where we have `isLoading` set to false and later set to true and then set to false again. + * Ideally, `isLoading` should be set initially to true and then set to false. We can achieve this by using logical OR operator. + */ + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const isLoading = isLoadingApp || !reportIDFromRoute || (!isSidebarLoaded && !isReportOpenInRHP) || PersonalDetailsUtils.isPersonalDetailsEmpty(); const shouldShowSkeleton = !isLinkedMessageAvailable && (isLinkingToMessage || @@ -730,7 +741,7 @@ function ReportScreen({ navigation={navigation} style={screenWrapperStyle} shouldEnableKeyboardAvoidingView={isTopMostReportId || isReportOpenInRHP} - testID={ReportScreen.displayName} + testID={`report-screen-${report.reportID}`} > {shouldShowReportActionList && ( ) => ReportActionsUtils.getSortedReportActionsForDisplay(allReportActions, true), }, + reportActionPages: { + key: ({route}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS_PAGES}${getReportID(route)}`, + }, reportNameValuePairs: { key: ({route}) => `${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${getReportID(route)}`, allowStaleData: true, diff --git a/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.tsx b/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.tsx index 136d8e5b59eb..5ab38cbf2e7e 100755 --- a/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.tsx +++ b/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.tsx @@ -166,7 +166,6 @@ function BaseReportActionContextMenu({ disabledIndexes, maxIndex: filteredContextMenuActions.length - 1, isActive: shouldEnableArrowNavigation, - disableCyclicTraversal: true, }); /** diff --git a/src/pages/home/report/ContextMenu/ContextMenuActions.tsx b/src/pages/home/report/ContextMenu/ContextMenuActions.tsx index bf634b4ac8ae..7102c9878dca 100644 --- a/src/pages/home/report/ContextMenu/ContextMenuActions.tsx +++ b/src/pages/home/report/ContextMenu/ContextMenuActions.tsx @@ -396,6 +396,9 @@ const ContextMenuActions: ContextMenuAction[] = [ setClipboardMessage(mentionWhisperMessage); } else if (ReportActionsUtils.isActionableTrackExpense(reportAction)) { setClipboardMessage(CONST.ACTIONABLE_TRACK_EXPENSE_WHISPER_MESSAGE); + } else if (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.SUBMITTED) { + const displayMessage = ReportUtils.getIOUSubmittedMessage(reportID); + Clipboard.setString(displayMessage); } else if (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.HOLD) { Clipboard.setString(Localize.translateLocal('iou.heldExpense')); } else if (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.UNHOLD) { diff --git a/src/pages/home/report/ReportActionItem.tsx b/src/pages/home/report/ReportActionItem.tsx index 2264feddd679..f0b36667e1dd 100644 --- a/src/pages/home/report/ReportActionItem.tsx +++ b/src/pages/home/report/ReportActionItem.tsx @@ -616,6 +616,8 @@ function ReportActionItem({ } else if (ReportActionsUtils.isOldDotReportAction(action)) { // This handles all historical actions from OldDot that we just want to display the message text children = ; + } else if (action.actionName === CONST.REPORT.ACTIONS.TYPE.SUBMITTED) { + children = ; } else if (action.actionName === CONST.REPORT.ACTIONS.TYPE.HOLD) { children = ; } else if (action.actionName === CONST.REPORT.ACTIONS.TYPE.HOLD_COMMENT) { diff --git a/src/pages/home/report/ReportActionItemCreated.tsx b/src/pages/home/report/ReportActionItemCreated.tsx index abf5d4dab8ee..2bfc46cfca89 100644 --- a/src/pages/home/report/ReportActionItemCreated.tsx +++ b/src/pages/home/report/ReportActionItemCreated.tsx @@ -68,7 +68,7 @@ function ReportActionItemCreated(props: ReportActionItemCreatedProps) { ReportUtils.navigateToDetailsPage(props.report)} - style={[styles.mh5, styles.mb3, styles.alignSelfStart]} + style={[styles.mh5, styles.mb3, styles.alignSelfStart, shouldDisableDetailPage && styles.cursorDefault]} accessibilityLabel={translate('common.details')} role={CONST.ROLE.BUTTON} disabled={shouldDisableDetailPage} diff --git a/src/pages/home/report/ReportActionItemSingle.tsx b/src/pages/home/report/ReportActionItemSingle.tsx index 7b0db3e0d844..53527e85b215 100644 --- a/src/pages/home/report/ReportActionItemSingle.tsx +++ b/src/pages/home/report/ReportActionItemSingle.tsx @@ -19,6 +19,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import ControlSelection from '@libs/ControlSelection'; import DateUtils from '@libs/DateUtils'; import Navigation from '@libs/Navigation/Navigation'; +import * as PolicyUtils from '@libs/PolicyUtils'; import {getReportActionMessage} from '@libs/ReportActionsUtils'; import * as ReportUtils from '@libs/ReportUtils'; import CONST from '@src/CONST'; @@ -112,21 +113,30 @@ function ReportActionItemSingle({ let secondaryAvatar: Icon; const primaryDisplayName = displayName; if (displayAllActors) { - // The ownerAccountID and actorAccountID can be the same if a user submits an expense back from the IOU's original creator, in that case we need to use managerID to avoid displaying the same user twice - const secondaryAccountId = ownerAccountID === actorAccountID || isInvoiceReport ? actorAccountID : ownerAccountID; - const secondaryUserAvatar = personalDetails?.[secondaryAccountId ?? -1]?.avatar ?? FallbackAvatar; - const secondaryDisplayName = ReportUtils.getDisplayNameForParticipant(secondaryAccountId); + if (ReportUtils.isInvoiceRoom(report) && !ReportUtils.isIndividualInvoiceRoom(report)) { + const secondaryPolicyID = report?.invoiceReceiver && 'policyID' in report.invoiceReceiver ? report.invoiceReceiver.policyID : '-1'; + const secondaryPolicy = PolicyUtils.getPolicy(secondaryPolicyID); + const secondaryPolicyAvatar = secondaryPolicy?.avatarURL ?? ReportUtils.getDefaultWorkspaceAvatar(secondaryPolicy?.name); - if (!isInvoiceReport) { - displayName = `${primaryDisplayName} & ${secondaryDisplayName}`; - } + secondaryAvatar = { + source: secondaryPolicyAvatar, + type: CONST.ICON_TYPE_WORKSPACE, + name: secondaryPolicy?.name, + id: secondaryPolicyID, + }; + } else { + // The ownerAccountID and actorAccountID can be the same if a user submits an expense back from the IOU's original creator, in that case we need to use managerID to avoid displaying the same user twice + const secondaryAccountId = ownerAccountID === actorAccountID || isInvoiceReport ? actorAccountID : ownerAccountID; + const secondaryUserAvatar = personalDetails?.[secondaryAccountId ?? -1]?.avatar ?? FallbackAvatar; + const secondaryDisplayName = ReportUtils.getDisplayNameForParticipant(secondaryAccountId); - secondaryAvatar = { - source: secondaryUserAvatar, - type: CONST.ICON_TYPE_AVATAR, - name: secondaryDisplayName ?? '', - id: secondaryAccountId, - }; + secondaryAvatar = { + source: secondaryUserAvatar, + type: CONST.ICON_TYPE_AVATAR, + name: secondaryDisplayName ?? '', + id: secondaryAccountId, + }; + } } else if (!isWorkspaceActor) { // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const avatarIconIndex = report.isOwnPolicyExpenseChat || ReportUtils.isPolicyExpenseChat(report) ? 0 : 1; diff --git a/src/pages/settings/Profile/PersonalDetails/PersonalAddressPage.tsx b/src/pages/settings/Profile/PersonalDetails/PersonalAddressPage.tsx new file mode 100644 index 000000000000..85402137fe6d --- /dev/null +++ b/src/pages/settings/Profile/PersonalDetails/PersonalAddressPage.tsx @@ -0,0 +1,61 @@ +import type {StackScreenProps} from '@react-navigation/stack'; +import React, {useMemo} from 'react'; +import type {OnyxEntry} from 'react-native-onyx'; +import {withOnyx} from 'react-native-onyx'; +import useLocalize from '@hooks/useLocalize'; +import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; +import AddressPage from '@pages/AddressPage'; +import * as PersonalDetails from '@userActions/PersonalDetails'; +import type {FormOnyxValues} from '@src/components/Form/types'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type SCREENS from '@src/SCREENS'; +import type {PrivatePersonalDetails} from '@src/types/onyx'; + +type PersonalAddressPageOnyxProps = { + /** User's private personal details */ + privatePersonalDetails: OnyxEntry; + /** Whether app is loading */ + isLoadingApp: OnyxEntry; +}; + +type PersonalAddressPageProps = StackScreenProps & PersonalAddressPageOnyxProps; + +/** + * 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 PersonalAddressPage({privatePersonalDetails, isLoadingApp = true}: PersonalAddressPageProps) { + const {translate} = useLocalize(); + const address = useMemo(() => privatePersonalDetails?.address, [privatePersonalDetails]); + + return ( + + ); +} + +PersonalAddressPage.displayName = 'PersonalAddressPage'; + +export default withOnyx({ + privatePersonalDetails: { + key: ONYXKEYS.PRIVATE_PERSONAL_DETAILS, + }, + isLoadingApp: { + key: ONYXKEYS.IS_LOADING_APP, + }, +})(PersonalAddressPage); diff --git a/src/pages/settings/Subscription/CardSection/BillingBanner/BillingBanner.tsx b/src/pages/settings/Subscription/CardSection/BillingBanner/BillingBanner.tsx index 4587dfee2fe6..bbb06dac4549 100644 --- a/src/pages/settings/Subscription/CardSection/BillingBanner/BillingBanner.tsx +++ b/src/pages/settings/Subscription/CardSection/BillingBanner/BillingBanner.tsx @@ -1,9 +1,10 @@ -import React from 'react'; +import React, {useMemo} from 'react'; import type {StyleProp, TextStyle, ViewStyle} from 'react-native'; import {View} from 'react-native'; import type {ValueOf} from 'type-fest'; import Icon from '@components/Icon'; import * as Expensicons from '@components/Icon/Expensicons'; +import {PressableWithoutFeedback} from '@components/Pressable'; import Text from '@components/Text'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -35,12 +36,50 @@ type BillingBannerProps = { /** An icon to be rendered instead of the RBR / GBR indicator. */ rightIcon?: IconAsset; + + /** Callback to be called when the right icon is pressed. */ + onRightIconPress?: () => void; + + /** Accessibility label for the right icon. */ + rightIconAccessibilityLabel?: string; }; -function BillingBanner({title, subtitle, icon, brickRoadIndicator, style, titleStyle, subtitleStyle, rightIcon}: BillingBannerProps) { +function BillingBanner({title, subtitle, icon, brickRoadIndicator, style, titleStyle, subtitleStyle, rightIcon, onRightIconPress, rightIconAccessibilityLabel}: BillingBannerProps) { const styles = useThemeStyles(); const theme = useTheme(); + const rightIconComponent = useMemo(() => { + if (rightIcon) { + return onRightIconPress && rightIconAccessibilityLabel ? ( + + + + ) : ( + + ); + } + + return ( + !!brickRoadIndicator && ( + + ) + ); + }, [brickRoadIndicator, onRightIconPress, rightIcon, rightIconAccessibilityLabel, styles.touchableButtonImage, theme.danger, theme.icon, theme.success]); + return ( {title} : title} {typeof subtitle === 'string' ? {subtitle} : subtitle} - {rightIcon ? ( - - ) : ( - !!brickRoadIndicator && ( - - ) - )} + {rightIconComponent} ); } diff --git a/src/pages/settings/Subscription/CardSection/BillingBanner/SubscriptionBillingBanner.tsx b/src/pages/settings/Subscription/CardSection/BillingBanner/SubscriptionBillingBanner.tsx index dce215e7dbbc..d949e2699e44 100644 --- a/src/pages/settings/Subscription/CardSection/BillingBanner/SubscriptionBillingBanner.tsx +++ b/src/pages/settings/Subscription/CardSection/BillingBanner/SubscriptionBillingBanner.tsx @@ -14,7 +14,7 @@ type SubscriptionBillingBannerProps = Omit ); } diff --git a/src/pages/settings/Subscription/CardSection/CardSection.tsx b/src/pages/settings/Subscription/CardSection/CardSection.tsx index e873569e4583..f3b78b3f2b95 100644 --- a/src/pages/settings/Subscription/CardSection/CardSection.tsx +++ b/src/pages/settings/Subscription/CardSection/CardSection.tsx @@ -1,6 +1,7 @@ -import React, {useCallback, useMemo, useState} from 'react'; +import React, {useCallback, useEffect, useMemo, useState} from 'react'; import {View} from 'react-native'; import {useOnyx} from 'react-native-onyx'; +import Button from '@components/Button'; import ConfirmModal from '@components/ConfirmModal'; import Icon from '@components/Icon'; import * as Expensicons from '@components/Icon/Expensicons'; @@ -8,6 +9,7 @@ import MenuItem from '@components/MenuItem'; import Section from '@components/Section'; import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; import useSubscriptionPlan from '@hooks/useSubscriptionPlan'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -15,6 +17,7 @@ import * as User from '@libs/actions/User'; import DateUtils from '@libs/DateUtils'; import Navigation from '@libs/Navigation/Navigation'; import * as SubscriptionUtils from '@libs/SubscriptionUtils'; +import * as Subscription from '@userActions/Subscription'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; @@ -24,6 +27,7 @@ import SubscriptionBillingBanner from './BillingBanner/SubscriptionBillingBanner import TrialStartedBillingBanner from './BillingBanner/TrialStartedBillingBanner'; import CardSectionActions from './CardSectionActions'; import CardSectionDataEmpty from './CardSectionDataEmpty'; +import type {BillingStatusResult} from './utils'; import CardSectionUtils from './utils'; function CardSection() { @@ -35,8 +39,10 @@ function CardSection() { const [privateSubscription] = useOnyx(ONYXKEYS.NVP_PRIVATE_SUBSCRIPTION); const [fundList] = useOnyx(ONYXKEYS.FUND_LIST); const subscriptionPlan = useSubscriptionPlan(); - const [network] = useOnyx(ONYXKEYS.NETWORK); - + const [subscriptionRetryBillingStatusPending] = useOnyx(ONYXKEYS.SUBSCRIPTION_RETRY_BILLING_STATUS_PENDING); + const [subscriptionRetryBillingStatusSuccessful] = useOnyx(ONYXKEYS.SUBSCRIPTION_RETRY_BILLING_STATUS_SUCCESSFUL); + const [subscriptionRetryBillingStatusFailed] = useOnyx(ONYXKEYS.SUBSCRIPTION_RETRY_BILLING_STATUS_FAILED); + const {isOffline} = useNetwork(); const defaultCard = useMemo(() => Object.values(fundList ?? {}).find((card) => card.accountData?.additionalData?.isBillingCard), [fundList]); const cardMonth = useMemo(() => DateUtils.getMonthNames(preferredLocale)[(defaultCard?.accountData?.cardMonth ?? 1) - 1], [defaultCard?.accountData?.cardMonth, preferredLocale]); @@ -47,12 +53,24 @@ function CardSection() { Navigation.resetToHome(); }, []); - const billingStatus = CardSectionUtils.getBillingStatus(translate, defaultCard?.accountData?.cardNumber ?? ''); + const [billingStatus, setBillingStatus] = useState(CardSectionUtils.getBillingStatus(translate, defaultCard?.accountData ?? {})); const nextPaymentDate = !isEmptyObject(privateSubscription) ? CardSectionUtils.getNextBillingDate() : undefined; const sectionSubtitle = defaultCard && !!nextPaymentDate ? translate('subscription.cardSection.cardNextPayment', {nextPaymentDate}) : translate('subscription.cardSection.subtitle'); + useEffect(() => { + setBillingStatus(CardSectionUtils.getBillingStatus(translate, defaultCard?.accountData ?? {})); + }, [subscriptionRetryBillingStatusPending, subscriptionRetryBillingStatusSuccessful, subscriptionRetryBillingStatusFailed, translate, defaultCard?.accountData]); + + const handleRetryPayment = () => { + Subscription.clearOutstandingBalance(); + }; + + const handleBillingBannerClose = () => { + setBillingStatus(undefined); + }; + let BillingBanner: React.ReactNode | undefined; if (CardSectionUtils.shouldShowPreTrialBillingBanner()) { BillingBanner = ; @@ -66,6 +84,8 @@ function CardSection() { isError={billingStatus.isError} icon={billingStatus.icon} rightIcon={billingStatus.rightIcon} + onRightIconPress={handleBillingBannerClose} + rightIconAccessibilityLabel={translate('common.close')} /> ); } @@ -105,6 +125,18 @@ function CardSection() { {isEmptyObject(defaultCard?.accountData) && } + + {billingStatus?.isRetryAvailable !== undefined && ( +