diff --git a/.eslintrc.js b/.eslintrc.js index 22bb0158bc8e..198620c70b0f 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -8,7 +8,7 @@ const restrictedImportPaths = [ '', "For 'useWindowDimensions', please use '@src/hooks/useWindowDimensions' instead.", "For 'TouchableOpacity', 'TouchableWithoutFeedback', 'TouchableNativeFeedback', 'TouchableHighlight', 'Pressable', please use 'PressableWithFeedback' and/or 'PressableWithoutFeedback' from '@components/Pressable' instead.", - "For 'StatusBar', please use '@src/libs/StatusBar' instead.", + "For 'StatusBar', please use '@libs/StatusBar' instead.", "For 'Text', please use '@components/Text' instead.", "For 'ScrollView', please use '@components/ScrollView' instead.", ].join('\n'), @@ -59,8 +59,12 @@ const restrictedImportPaths = [ }, { name: 'expensify-common', - importNames: ['Device'], - message: "Do not import Device directly, it's known to make VSCode's IntelliSense crash. Please import the desired module from `expensify-common/dist/Device` instead.", + importNames: ['Device', 'ExpensiMark'], + message: [ + '', + "For 'Device', do not import it directly, it's known to make VSCode's IntelliSense crash. Please import the desired module from `expensify-common/dist/Device` instead.", + "For 'ExpensiMark', please use '@libs/Parser' instead.", + ].join('\n'), }, ]; @@ -109,7 +113,6 @@ module.exports = { }, rules: { // TypeScript specific rules - '@typescript-eslint/no-unsafe-assignment': 'off', '@typescript-eslint/prefer-enum-initializers': 'error', '@typescript-eslint/no-var-requires': 'off', '@typescript-eslint/no-non-null-assertion': 'error', diff --git a/.github/actions/javascript/bumpVersion/bumpVersion.ts b/.github/actions/javascript/bumpVersion/bumpVersion.ts index ff43ab9ee5c5..92b81836ce13 100644 --- a/.github/actions/javascript/bumpVersion/bumpVersion.ts +++ b/.github/actions/javascript/bumpVersion/bumpVersion.ts @@ -49,7 +49,7 @@ if (!semanticVersionLevel || !versionUpdater.isValidSemverLevel(semanticVersionL console.log(`Invalid input for 'SEMVER_LEVEL': ${semanticVersionLevel}`, `Defaulting to: ${semanticVersionLevel}`); } -const {version: previousVersion}: PackageJson = JSON.parse(fs.readFileSync('./package.json').toString()); +const {version: previousVersion} = JSON.parse(fs.readFileSync('./package.json').toString()) as PackageJson; if (!previousVersion) { core.setFailed('Error: Could not read package.json'); } diff --git a/.github/actions/javascript/createOrUpdateStagingDeploy/createOrUpdateStagingDeploy.ts b/.github/actions/javascript/createOrUpdateStagingDeploy/createOrUpdateStagingDeploy.ts index aed8b9dcba0a..caff455e9fa5 100644 --- a/.github/actions/javascript/createOrUpdateStagingDeploy/createOrUpdateStagingDeploy.ts +++ b/.github/actions/javascript/createOrUpdateStagingDeploy/createOrUpdateStagingDeploy.ts @@ -8,13 +8,13 @@ import GitUtils from '@github/libs/GitUtils'; type IssuesCreateResponse = Awaited>['data']; -type PackageJSON = { +type PackageJson = { version: string; }; async function run(): Promise { // Note: require('package.json').version does not work because ncc will resolve that to a plain string at compile time - const packageJson: PackageJSON = JSON.parse(fs.readFileSync('package.json', 'utf8')); + const packageJson = JSON.parse(fs.readFileSync('package.json', 'utf8')) as PackageJson; const newVersionTag = packageJson.version; try { diff --git a/.github/actions/javascript/getGraphiteString/getGraphiteString.ts b/.github/actions/javascript/getGraphiteString/getGraphiteString.ts index 5231caa79ed5..93d5d8a9618b 100644 --- a/.github/actions/javascript/getGraphiteString/getGraphiteString.ts +++ b/.github/actions/javascript/getGraphiteString/getGraphiteString.ts @@ -33,7 +33,7 @@ const run = () => { } try { - const current: RegressionEntry = JSON.parse(entry); + const current = JSON.parse(entry) as RegressionEntry; // Extract timestamp, Graphite accepts timestamp in seconds if (current.metadata?.creationDate) { diff --git a/.github/actions/javascript/getPreviousVersion/getPreviousVersion.ts b/.github/actions/javascript/getPreviousVersion/getPreviousVersion.ts index a178d4073cbb..7799ffe7c9ec 100644 --- a/.github/actions/javascript/getPreviousVersion/getPreviousVersion.ts +++ b/.github/actions/javascript/getPreviousVersion/getPreviousVersion.ts @@ -11,7 +11,7 @@ function run() { core.setFailed(`'Error: Invalid input for 'SEMVER_LEVEL': ${semverLevel}`); } - const {version: currentVersion}: PackageJson = JSON.parse(readFileSync('./package.json', 'utf8')); + const {version: currentVersion} = JSON.parse(readFileSync('./package.json', 'utf8')) as PackageJson; if (!currentVersion) { core.setFailed('Error: Could not read package.json'); } diff --git a/.github/actions/javascript/validateReassureOutput/validateReassureOutput.ts b/.github/actions/javascript/validateReassureOutput/validateReassureOutput.ts index ad0f393a96a2..d843caf61518 100644 --- a/.github/actions/javascript/validateReassureOutput/validateReassureOutput.ts +++ b/.github/actions/javascript/validateReassureOutput/validateReassureOutput.ts @@ -3,7 +3,7 @@ import type {CompareResult, PerformanceEntry} from '@callstack/reassure-compare/ import fs from 'fs'; const run = (): boolean => { - const regressionOutput: CompareResult = JSON.parse(fs.readFileSync('.reassure/output.json', 'utf8')); + const regressionOutput = JSON.parse(fs.readFileSync('.reassure/output.json', 'utf8')) as CompareResult; const countDeviation = Number(core.getInput('COUNT_DEVIATION', {required: true})); const durationDeviation = Number(core.getInput('DURATION_DEVIATION_PERCENTAGE', {required: true})); diff --git a/.github/workflows/reassurePerformanceTests.yml b/.github/workflows/reassurePerformanceTests.yml index a695c0acf942..a2aadc331f19 100644 --- a/.github/workflows/reassurePerformanceTests.yml +++ b/.github/workflows/reassurePerformanceTests.yml @@ -48,12 +48,12 @@ jobs: git fetch origin "$BASELINE_BRANCH" --no-tags --depth=1 git switch "$BASELINE_BRANCH" npm install --force - npx reassure --baseline + NODE_OPTIONS=--experimental-vm-modules npx reassure --baseline git switch --force --detach - git merge --no-commit --allow-unrelated-histories "$BASELINE_BRANCH" -X ours git checkout --ours . npm install --force - npx reassure --branch + NODE_OPTIONS=--experimental-vm-modules npx reassure --branch - name: Validate output.json id: validateReassureOutput 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 0f69346a44bc..410fd3163051 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 1009000305 - versionName "9.0.3-5" + versionCode 1009000510 + versionName "9.0.5-10" // 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/circular-arrow-backwards.svg b/assets/images/circular-arrow-backwards.svg new file mode 100644 index 000000000000..209c0aea5fa7 --- /dev/null +++ b/assets/images/circular-arrow-backwards.svg @@ -0,0 +1,9 @@ + + + + + diff --git a/assets/images/computer.svg b/assets/images/computer.svg index 9c2628245eb1..be9eca391e0b 100644 --- a/assets/images/computer.svg +++ b/assets/images/computer.svg @@ -1,216 +1 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file + \ No newline at end of file diff --git a/assets/images/expensifyCard/cardIllustration.svg b/assets/images/expensifyCard/cardIllustration.svg new file mode 100644 index 000000000000..c81bb21568a7 --- /dev/null +++ b/assets/images/expensifyCard/cardIllustration.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/images/integrationicons/netsuite-icon-square.svg b/assets/images/integrationicons/netsuite-icon-square.svg index d4f19f4f44c0..1b4557c5a044 100644 --- a/assets/images/integrationicons/netsuite-icon-square.svg +++ b/assets/images/integrationicons/netsuite-icon-square.svg @@ -1,57 +1 @@ - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/assets/images/integrationicons/sage-intacct-icon-square.svg b/assets/images/integrationicons/sage-intacct-icon-square.svg index 33d86259a2d1..fe10342d711e 100644 --- a/assets/images/integrationicons/sage-intacct-icon-square.svg +++ b/assets/images/integrationicons/sage-intacct-icon-square.svg @@ -1,23 +1 @@ - - - - - - - - - - - \ No newline at end of file + \ No newline at end of file diff --git a/assets/images/simple-illustrations/simple-illustration__virtualcard.svg b/assets/images/simple-illustrations/simple-illustration__virtualcard.svg new file mode 100644 index 000000000000..2c1f538102a2 --- /dev/null +++ b/assets/images/simple-illustrations/simple-illustration__virtualcard.svg @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/config/electronBuilder.config.js b/config/electronBuilder.config.js index 5a995fb5de91..ad3a23407b89 100644 --- a/config/electronBuilder.config.js +++ b/config/electronBuilder.config.js @@ -47,7 +47,7 @@ module.exports = { }, target: [ { - target: 'dmg', + target: 'default', arch: ['universal'], }, ], 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/_includes/lhn-template.html b/docs/_includes/lhn-template.html index 80302f33f52e..32078c1a8de6 100644 --- a/docs/_includes/lhn-template.html +++ b/docs/_includes/lhn-template.html @@ -21,25 +21,25 @@ {% for platform in site.data.routes.platforms %} {% if platform.href == activePlatform %}
  • - - + {% for hub in platform.hubs %}
      {% if hub.href == activeHub %} - - +
        {% for section in hub.sections %}
      • {% if section.href == activeSection %} - - +
          {% for article in section.articles %} {% assign article_href = section.href | append: '/' | append: article.href %} diff --git a/docs/_sass/_main.scss b/docs/_sass/_main.scss index 528bdcf28871..82446fe08b3a 100644 --- a/docs/_sass/_main.scss +++ b/docs/_sass/_main.scss @@ -749,6 +749,7 @@ button { width: 20px; height: 20px; cursor: pointer; + display: inline-block; } .homepage { 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/Admin-Card-Settings-and-Features.md b/docs/articles/expensify-classic/expensify-card/Admin-Card-Settings-and-Features.md index 043cc4be1e26..b9938b058ef6 100644 --- a/docs/articles/expensify-classic/expensify-card/Admin-Card-Settings-and-Features.md +++ b/docs/articles/expensify-classic/expensify-card/Admin-Card-Settings-and-Features.md @@ -1,174 +1,134 @@ --- title: Admin Card Settings and Features -description: An in-depth look into the Expensify Card program's admin controls and settings. +description: A deep dive into the available controls and settings for the Expensify Card. --- +# Expensify Visa® Commercial Card Overview +The Expensify Visa® Commercial Card offers various settings to help admins manage expenses and card usage efficiently. Here’s how to use these features: -# Overview +## Smart Limits +Smart Limits allow you to set custom spending limits for each Expensify cardholder or default limits for groups. Setting a Smart Limit activates an Expensify card for your user and issues a virtual card for immediate use. -The Expensify Visa® Commercial Card offers a range of settings and functionality to customize how admins manage expenses and card usage in Expensify. To start, we'll lay out the best way to make these options work for you. +#### Set Limits for Individual Cardholders +As a Domain Admin, you can set or edit Custom Smart Limits for a card: +1. Go to _**Settings > Domains > Domain Name > Company Cards**_. +2. Click **Edit Limit** to set the limit. -Set Smart Limits to control card spend. Smart Limits are spend limits that can be set for individual cards or specific groups. Once a given Smart Limit is reached, the card is temporarily disabled until expenses are approved. +This limit restricts the amount of unapproved (unsubmitted and processing) expenses a cardholder can incur. Once the limit is reached, the cardholder cannot use their card until they submit outstanding expenses and have their card spend approved. If you set the Smart Limit to $0, the user’s card cannot be used. -Monitor spend using your Domain Limit and the Reconciliation Dashboard. -Your Domain Limit is the total Expensify Card limit across your entire organization. No member can spend more than what's available here, no matter what their individual Smart Limit is. A Domain Limit is dynamic and depends on a number of factors, which we'll explain below. +#### Set Default Group Limits +Domain Admins can set or edit custom Smart Limits for a domain group: -Decide the settlement model that works best for your business -Monthly settlement is when your Expensify Card balance is paid in full on a certain day each month. Though the Expensify Card is set to settle daily by default, any Domain Admin can change this setting to monthly. +1. Go to _**Settings > Domains > Domain Name > Groups**_. +2. Click on the limit in-line for your chosen group and amend the value. -Now, let's get into the mechanics of each piece mentioned above. +This limit applies to all members of the Domain Group who do not have an individual limit set via _**Settings > Domains > Domain Name > Company Cards**_. -# How to set Smart Limits -Smart Limits allow you to set a custom spend limit for each Expensify cardholder, or default limits for groups. Setting a Smart Limit is the step that activates an Expensify card for your user (and issues a virtual card for immediate use). +#### Refreshing Smart Limits +To let cardholders continue spending, you can approve their pending expenses via the Reconciliation tab. This frees up their limit, allowing them to use their card again. -## Set limits for individual cardholders -As a Domain Admin, you can set or edit Custom Smart Limits for a card by going to Settings > Domains > Domain Name > Company Cards. Simply click Edit Limit to set the limit. This limit will restrict the amount of unapproved (unsubmitted and Processing) expenses that a cardholder can incur. After the limit is reached, the cardholder won't be able to use their card until they submit outstanding expenses and have their card spend approved. If you set the Smart Limit to $0, the user's card can't be used. -## Set default group limits -Domain Admins can set or edit custom Smart Limits for a domain group by going to Settings > Domains > Domain Name > Groups. Just click on the limit in-line for your chosen group and amend the value. +To check an unapproved card balance and approve expenses: +1. Click on **Reconciliation** and enter a date range. +2. Click on the Unapproved total to see what needs approval. +3. You can add to a new report or approve an existing report from here. -This limit will apply to all members of the Domain Group who do not have an individual limit set via Settings > Domains > Domain Name > Company Cards. +You can also increase a Smart Limit at any time by clicking **Edit Limit**. -## Refreshing Smart Limits -To let cardholders keep spending, you can approve their pending expenses via the Reconciliation tab. This will free up their limit, allowing them to use their card again. +### Understanding Your Domain Limit +To ensure you have the most accurate Domain Limit for your company, follow these steps: -To check an unapproved card balance and approve expenses, click on Reconciliation and enter a date range, then click though the Unapproved total to see what needs approving. You can add to a new report or approve an existing report from here. +1. **Connect Your Bank Account:** Go to _**Settings > Account > Payments > Add Verified Bank Account**_ and connect via Plaid. -You can also increase a Smart Limit at any time by clicking Edit Limit. +2. **Request a Custom Limit:** If your bank isn’t supported or you’re experiencing connection issues, you can request a custom limit at _**Settings > Domains > Domain Name > Company Cards > Request Limit Increase**_. Note that you’ll need to provide three months of unredacted bank statements for review by our risk management team. -# Understanding your Domain Limit +### Factors Affecting Your Domain Limit +Your Domain Limit may fluctuate due to several factors: -To get the most accurate Domain Limit for your company, connect your bank account via Plaid under Settings > Account > Payments > Add Verified Bank Account. +- **Available Funds in Your Verified Business Bank Account:** We regularly monitor balances via Plaid. A sudden decrease in balance within the last 24 hours may impact your limit. For accounts with 'sweep' functionality, maintain a sufficient balance even when sweeping daily. -If your bank isn't supported or you're having connection issues, you can request a custom limit under Settings > Domains > Domain Name > Company Cards > Request Limit Increase. As a note, you'll need to provide three months of unredacted bank statements for review by our risk management team. +- **Pending Expenses:** Check the Reconciliation Dashboard for large pending expenses that could affect your available balance. Your Domain Limit automatically adjusts to include pending expenses. -Your Domain Limit may fluctuate from time to time based on various factors, including: +- **Processing Settlements:** Settlements typically take about three business days to process and clear. Multiple large settlements over consecutive days may affect your Domain Limit, which updates dynamically once settlements are cleared. -- Available funds in your Verified Business Bank Account: We regularly check bank balances via Plaid. A sudden drop in balance within the last 24 hours may affect your limit. For 'sweep' accounts, be sure to maintain a substantial balance even if you're sweeping daily. -- Pending expenses: Review the Reconciliation Dashboard to check for large pending expenses that may impact your available balance. Your Domain Limit will adjust automatically to include pending expenses. -- Processing settlements: Settlements need about three business days to process and clear. Several large settlements over consecutive days may impact your Domain Limit, which will dynamically update when settlements have cleared. +Please note: If your Domain Limit is reduced to $0, cardholders cannot make purchases, even if they have higher Smart Limits set on their individual cards. -As a note, if your Domain Limit is reduced to $0, your cardholders can't make purchases even if they have a larger Smart Limit set on their individual cards. +## Reconciling Expenses and Settlements +Reconciling expenses ensures your financial records are accurate and up-to-date. Follow these steps to review and reconcile expenses associated with your Expensify Cards: -# How to reconcile Expensify Cards -## How to reconcile expenses -Reconciling expenses is essential to ensuring your financial records are accurate and up-to-date. +#### How to Reconcile Expenses: +1. Go to _**Settings > Domains > Domain Name > Company Cards > Reconciliation > Expenses**_. +2. Enter your start and end dates, then click *Run*. +3. The Imported Total will display all Expensify Card transactions for the period. +4. You'll see a list of all Expensify Cards, the total spend on each card, and a snapshot of expenses that have been approved and have not been approved (Approved Total and Unapproved Total, respectively). +5. Click on the amounts to view the associated expenses. -Follow the steps below to quickly review and reconcile expenses associated with your Expensify Cards: +#### How to Reconcile Settlements: +A settlement is the payment to Expensify for purchases made using the Expensify Cards. The program can settle on either a daily or monthly basis. Note that not all transactions in a settlement will be approved when running reconciliation. -1. Go to Settings > Domains > Domain Name > Company Cards > Reconciliation > Expenses -2. Enter your start and end dates, then click Run -3. The Imported Total will show all Expensify Card transactions for the period -4. You'll also see a list of all Expensify Cards, the total spend on each card, and a snapshot of expenses that have and have not been approved (Approved Total and Unapproved Total, respectively) -By clicking on the amounts, you can view the associated expenses +1. Log into the Expensify web app. +2. Click _**Settings > Domains > Domain Name > Company Cards > Reconciliation > `Settlements**_. +3. Use the Search function to generate a statement for the specific period you need. +The search results will include the following info for each entry: +- **Date:** When a purchase was made or funds were debited for payments. +- **Posted Date:** When the purchase transaction is posted. +- **Entry ID:** A unique number grouping card payments and transactions settled by those payments. +- **Amount:** The amount debited from the Business Bank Account for payments. +- **Merchant:** The business where a purchase was made. +- **Card:** Refers to the Expensify Card number and cardholder’s email address. +- **Business Account:** The business bank account connected to Expensify that the settlement is paid from. +- **Transaction ID:** A special ID that helps Expensify support locate transactions if there’s an issue. -## How to reconcile settlements -A settlement is the payment to Expensify for the purchases made using the Expensify Cards. +Review the individual transactions (debits) and the payments (credits) that settled them. Each cardholder will have a virtual and a physical card listed, handled the same way for settlements, reconciliation, and exporting. -The Expensify Card program can settle on either a daily or monthly basis. One thing to note is that not all transactions in a settlement will be approved when running reconciliation. +4. Click **Download CSV** for reconciliation. This will list everything you see on the screen. +5. To reconcile pre-authorizations, use the Transaction ID column in the CSV file to locate the original purchase. +6. Review account payments: You’ll see payments made from the accounts listed under _**Settings > Account > Payments > Bank Accounts**_. Payment data won’t show for deleted accounts. -You can view the Expensify Card settlements under Settings > Domains > Domain Name > Company Cards > Reconciliation > Settlements. +Use the Reconciliation Dashboard to confirm the status of expenses missing from your accounting system. It allows you to view both approved and unapproved expenses within your selected date range that haven’t been exported yet. -By clicking each settlement amount, you can see the transactions contained in that specific payment amount. +### Set a Preferred Workspace +Many customers find it helpful to separate their company card expenses from other types of expenses for easier coding. To do this, create a separate workspace specifically for card expenses. -Follow the below steps to run reconciliation on the Expensify Card settlements: +**Using a Preferred Workspace:** +Combine this feature with Scheduled Submit to automatically add new card expenses to reports connected to your card-specific workspace. -1. Log into the Expensify web app -2. Click Settings > Domains > Domain Name > Company Cards > Reconciliation tab > Settlements -3. Use the Search function to generate a statement for the specific period you need -4. The search results will include the following info for each entry: - - Date: when a purchase was made or funds were debited for payments - - Posted Date: when the purchase transaction posted - - Entry ID: a unique number grouping card payments and transactions settled by those payments - - Amount: the amount debited from the Business Bank Account for payments - - Merchant: the business where a purchase was made - - Card: refers to the Expensify Card number and cardholder's email address - - Business Account: the business bank account connected to Expensify that the settlement is paid from - - Transaction ID: a special ID that helps Expensify support locate transactions if there's an issue +### Change the Settlement Account +You can change your settlement account to any verified business bank account in Expensify. If your current bank account is closing, make sure to set up a replacement as soon as possible. -5. Review the individual transactions (debits) and the payments (credits) that settled them -6. Every cardholder will have a virtual and a physical card listed. They're handled the same way for settlements, reconciliation, and exporting. -7. Click Download CSV for reconciliation -8. This will list everything that you see on screen -9. To reconcile pre-authorizations, you can use the Transaction ID column in the CSV file to locate the original purchase -10. Review account payments -11. You'll see payments made from the accounts listed under Settings > Account > Payments > Bank Accounts. Payment data won't show for deleted accounts. +#### Steps to Select a Different Settlement Account: +1. Go to _**Settings > Domains > Domain Name > Company Cards > Settings**_ tab. +2. Use the Expensify Card settlement account dropdown to select a new account. +3. Click **Save**. -You can use the Reconciliation Dashboard to confirm the status of expenses that are missing from your accounting system. It allows you to view both approved and unapproved expenses within your selected date range that haven't been exported yet. +### Change the Settlement Frequency +By default, Expensify Cards settle daily. However, you can switch to monthly settlements. +#### Monthly Settlement Requirements: + - The settlement account must not have had a negative balance in the last 90 days. + - There will be an initial settlement for any outstanding spending before the switch. + - The settlement date going forward will be the date you switch (e.g., if you switch on September 15th, future settlements will be on the 15th of each month). -# Deep dive -## Set a preferred workspace -Some customers choose to split their company card expenses from other expense types for coding purposes. Most commonly this is done by creating a separate workspace for card expenses. +#### Steps to Change the Settlement Frequency: +1. Go to _**Settings > Domains > Domain Name > Company Cards > Settings**_ tab. +2. Click the **Settlement Frequency** dropdown and select **Monthly**. +3. Click **Save** to confirm the change. -You can use the preferred workspace feature in conjunction with Scheduled Submit to make sure all newly imported card expenses are automatically added to reports connected to your card-specific workspace. +### Declined Expensify Card Transactions +If you have 'Receive real-time alerts' enabled, you'll get a notification explaining why a transaction was declined. To enable alerts: +1. Open the mobile app. +2. Click the three-bar icon in the upper-left corner. +3. Go to Settings. +4. Toggle 'Receive real-time alerts' on. -## How to change your settlement account -You can change your settlement account to any other verified business bank account in Expensify. If your bank account is closing, make sure you set up the replacement bank account in Expensify as early as possible. +If you or your employees notice any unfamiliar purchases or need a new card, go to _**Settings > Account > Credit Card Import**_ and click on **Request a New Card**. -To select a different settlement account: +#### Common Reasons for Declines: +- **Insufficient Card Limit:** If a transaction exceeds your card's limit, it will be declined. Always check your balance under _**Settings > Account > Credit Card Import**_ on the web or mobile app. Approve pending expenses to free up your limit. -1. Go to Settings > Domains > Domain Name > Company Cards > Settings tab -2. Use the Expensify Card settlement account dropdown to select a new account -3. Click Save +- **Card Not Activated or Canceled:** Transactions won't process if the card hasn't been activated or has been canceled. +- **Incorrect Card Information:** Entering incorrect card details, such as the CVC, ZIP, or expiration date, will lead to declines. -## Change the settlement frequency +- **Suspicious Activity:** Expensify may block transactions if unusual activity is detected. This could be due to irregular spending patterns, risky vendors, or multiple rapid transactions. Check your Expensify Home page to approve unusual merchants. If further review is needed, Expensify will perform a manual due diligence check and lock your cards temporarily. -By default, the Expensify Cards settle on a daily cadence. However, you can choose to have the cards settle on a monthly basis. - -1. Monthly settlement is only available if the settlement account hasn't had a negative balance in the last 90 days -2. There will be an initial settlement to settle any outstanding spend that happened before switching the settlement frequency -3. The date that the settlement is changed to monthly is the settlement date going forward (e.g. If you switch to monthly settlement on September 15th, Expensify Cards will settle on the 15th of each month going forward) - -To change the settlement frequency: -1. Go to Settings > Domains > Domain Name > Company Cards > Settings tab -2. Click the Settlement Frequency dropdown and select Monthly -3. Click Save to confirm the change - - - -## Declined Expensify Card transactions -As long as you have 'Receive realtime alerts' enabled, you'll get a notification explaining the decline reason. You can enable alerts in the mobile app by clicking on three-bar icon in the upper-left corner > Settings > toggle Receive realtime alerts on. - -If you ever notice any unfamiliar purchases or need a new card, go to Settings > Account > Credit Card Import and click on Request a New Card right away. - -Here are some reasons an Expensify Card transaction might be declined: - -1. You have an insufficient card limit - - If a transaction amount exceeds the available limit on your Expensify Card, the transaction will be declined. It's essential to be aware of the available balance before making a purchase to avoid this - you can see the balance under Settings > Account > Credit Card Import on the web app or mobile app. Submitting expenses and having them approved will free up your limit for more spend. - -2. Your card hasn't been activated yet, or has been canceled - - If the card has been canceled or not yet activated, it won't process any transactions. - -3. Your card information was entered incorrectly. Entering incorrect card information, such as the CVC, ZIP or expiration date will also lead to declines. - -4. There was suspicious activity - - If Expensify detects unusual or suspicious activity, we may block transactions as a security measure. This could happen due to irregular spending patterns, attempted purchases from risky vendors, or multiple rapid transactions. Check your Expensify Home page to approve unsual merchants and try again. - If the spending looks suspicious, we may do a manual due diligence check, and our team will do this as quickly as possible - your cards will all be locked while this happens. -5. The merchant is located in a restricted country - - Some countries may be off-limits for transactions. If a merchant or their headquarters (billing address) are physically located in one of these countries, Expensify Card purchases will be declined. This list may change at any time, so be sure to check back frequently: Belarus, Burundi, Cambodia, Central African Republic, Democratic Republic of the Congo, Cuba, Iran, Iraq, North Korea, Lebanon, Libya, Russia, Somalia, South Sudan, Syrian Arab Republic, Tanzania, Ukraine, Venezuela, Yemen, and Zimbabwe. - -{% include faq-begin.md %} -## What happens when I reject an Expensify Card expense? -Rejecting an Expensify Card expense from an Expensify report will simply allow it to be reported on a different report. - -If an Expensify Card expense needs to be rejected, you can reject the report or the specific expense so it can be added to a different report. The rejected expense will become Unreported and return to the submitter's Expenses page. - -If you want to dispute a card charge, please message Concierge to start the dispute process. - -If your employee has accidentally made an unauthorised purchase, you will need to work that out with the employee to determine how they will pay back your company. - - -## What happens when an Expensify Card transaction is refunded? - - -The way a refund is displayed in Expensify depends on the status of the expense (pending or posted) and whether or not the employee also submitted an accompanying SmartScanned receipt. Remember, a SmartScanned receipt will auto-merge with the Expensify Card expense. - -- Full refunds: -If a transaction is pending and doesn't have a receipt attached (except for eReceipts), getting a full refund will make the transaction disappear. -If a transaction is pending and has a receipt attached (excluding eReceipts), a full refund will zero-out the transaction (amount becomes zero). -- Partial refunds: -If a transaction is pending, a partial refund will reduce the amount of the transaction. -- If a transaction is posted, a partial refund will create a negative transaction for the refund amount. - -{% include faq-end.md %} +- **Merchant in a Restricted Country:** Transactions will be declined if the merchant is in a restricted country. 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/Cardholder-Settings-and-Features.md b/docs/articles/expensify-classic/expensify-card/Cardholder-Settings-and-Features.md index f24ed57dc655..38686462a1c2 100644 --- a/docs/articles/expensify-classic/expensify-card/Cardholder-Settings-and-Features.md +++ b/docs/articles/expensify-classic/expensify-card/Cardholder-Settings-and-Features.md @@ -3,78 +3,55 @@ title: Cardholder Settings and Features description: Expensify Card Settings for Employees --- -# How to use your Expensify Visa® Commercial Card -Once you receive your card, you can start using it right away. +# Using Your Expensify Visa® Commercial Card -First, you'll want to take note of the Smart Limit tied to your card – this is listed in your card settings via **Settings > Account > Credit Card Import**. This limit represents the total amount of unapproved expenses you can have on the card. +### Activate Your Card +You can start using your card immediately upon receipt by logging into your Expensify account, heading to your Home tab, and following the prompts on the _**Activate your Expensify Card**_ task. -It's crucial to continuously submit your expenses promptly, as that'll ensure they can be approved and restore your full limit. You can always chat with your admin if you need your limit adjusted. +### Review your Card's Smart Limit +Check your card’s Smart Limit via _**Settings > Account > Credit Card Import**_: +- This limit is the total amount of unapproved expenses you can have on the card. +- If a purchase is more than your card's Smart Limit, it will be declined. -You can swipe your Expensify Card like you would with any other card. As you make purchases, you'll get instant alerts on your phone letting you know if you need to SmartScan receipts. Any SmartScanned receipts should merge with the card expense automatically. +## Managing Expenses +- **Submit Expenses Promptly**: Submit your expenses regularly to restore your full limit. Contact your admin if you need a limit adjustment. +- **Using Your Card**: Swipe your Expensify Card like any other card. You’ll receive instant alerts on your phone for SmartScan receipts. SmartScanned receipts will merge automatically with card expenses. +- **eReceipts**: If your organization doesn’t require itemized receipts, Expensify will generate IRS-compliant eReceipts for all non-lodging transactions. +- **Reporting Expenses**: Report and submit Expensify Card expenses as usual. Approved expenses refresh your Smart Limit. -If your organization doesn't require itemized receipts, you can rely on eReceipts instead. As long as the expense isn't lodging-related, Expensify will automatically generate an IRS-compliant eReceipt for every transaction. +## Enabling Notifications +Download the Expensify mobile app and enable push notifications to stay updated on spending activity and potential fraud. -You can report and submit Expensify Card expenses just like any other expenses. As they're approved, your Smart Limit will be refreshed accordingly, allowing you to keep making purchases. +#### For iPhone: +1. Open the Expensify app and tap the three-bar icon in the upper-left corner. +2. Tap _**Settings > enable Receive real-time alerts**_. +3. Accept the confirmation to access your iPhone’s notification settings for Expensify. +4. Turn on **Allow Notifications** and select your notification types. -## Enable Notifications -Download the Expensify mobile app and enable push notifications to stay current on your spending activity. Your card is connected to your Expensify account, so each transaction on your card will trigger a push notification. We'll also send you a push notification if we detect potentially fraudulent activity and allow you to confirm your purchase. - -Follow the steps below to enable real-time alerts on your mobile device. - -**If you have an iPhone**: -1. Open the Expensify app and tap the three-bar icon in the upper-left corner -2. Tap **Settings** and enable **Receive realtime alerts** -3. Accept the confirmation dialogue to go to your iPhone's notification settings for Expensify. Turn on Allow Notifications, and choose the notification types you’d like! - -**If you have an Android**: -1. Go to Settings and open 'Apps and Notifications'. +#### For Android: +1. Go to _**Settings > Apps and Notifications**_. 2. Find and open Expensify and enable notifications. -3. Customize your alerts. Depending on your phone model, you may have extra options to customize the types of notifications you receive. - -## Your virtual card -Once you're assigned a limit, you'll be able to use your virtual card immediately. You can view your virtual card details via **Settings > Account > Credit Card Import > Show Details**. Keep in mind that your virtual card and physical card share a limit. - -The virtual Expensify Card includes a card number, expiration date, and security code (CVC). You can use the virtual card for online purchases, in-app transactions, and in-person payments once it's linked to a mobile wallet (Apple Pay or Google Pay). - -## How to access your virtual card details -Here's how to access your virtual card details via the Expensify mobile app: -1. Tap the three-bar icon in the upper-left corner -2. Tap **Settings > Connected Cards** -3. Under **Virtual Card**, tap **Show Details** - -From there, you can view your virtual card's number, CVV, expiration date, and billing address. - -Here's how to access your virtual card details via the Expensify web app: -1. Head to **Settings > Account > Credit Card Import** -2. Under **Virtual Card**, click **Show Details** - -From there, you can view your virtual card's card number, CVV, expiration date, and billing address. - -## How to add your virtual card to a digital wallet (Apple Pay or Google Pay) - -To use the Expensify Card for contactless payment, add it to your digital wallet from the mobile app: -1. Tap the three-bar icon in the upper-left corner -2. Tap **Settings > Connected Cards** -3. Depending on your device, tap **Add to Apple Wallet** or **Add to Gpay** -4. Complete the remaining steps - -## Expensify Card declines -As long as you've enabled 'Receive real-time alerts', you'll get a notification explaining the reason for each decline. You can enable alerts in the mobile app by clicking on the three-bar icon in the upper-left corner > **Settings** > toggle **Receive real-time alerts**. - -Here are some reasons an Expensify Card transaction might be declined: - -- You have an insufficient card limit - - If a transaction exceeds your Expensify Card's available limit, the transaction will be declined. You can see the remaining limit in the mobile app under **Settings > Connected Cards** or in the web app under **Settings > Account > Credit Card Import**. - - Submitting expenses and getting them approved will free up your limit for more spending. - -- Your card isn't active yet or it was disabled by your Domain Admin -- Your card information was entered incorrectly with the merchant. Entering incorrect card information, such as the CVC, ZIP, or expiration date, will also lead to declines. -There was suspicious activity -- If Expensify detects unusual or suspicious activity, we may block transactions as a security measure - - This could happen due to irregular spending patterns, attempted purchases from risky vendors, or multiple rapid transactions. - - Check your Expensify Home page to approve unusual merchants and try again. - - If the spending looks suspicious, we may complete a manual due diligence check, and our team will do this as quickly as possible - your cards will all be locked while this happens. -- The merchant is located in a restricted country +3. Customize your alerts based on your phone model. + +## Using Your Virtual Card +- **Access Details**: You can view your virtual card details (card number, expiration date, CVC) via _**Settings > Account > Credit Card Import > Show Details**_. The virtual and physical cards share the same limit. +- **Purchases**: Use the virtual card for online, in-app, and in-person payments when linked to a mobile wallet (Apple Pay or Google Pay). + +#### Adding to a Digital Wallet +To add your Expensify Card to a digital wallet, follow the steps below: + 1. Tap the three-bar icon in the upper-left corner. + 2. Tap _**Settings > Connected Cards**_. + 3. Tap **Add to Apple Wallet** or **Add to Gpay**, depending on your device. + 4. Complete the steps as prompted. + +## Handling Declines +- **Real-Time Alerts**: Enable real-time alerts in the mobile app (_**Settings > toggle Receive real-time alerts**_) to get notifications for declines. +- **Common Decline Reasons**: + - **Insufficient Limit**: Transactions exceeding the available limit will be declined. You can check your limit in _**Settings > Connected Cards**_ or _**Settings > Account > Credit Card Import**_. + - **Inactive or Disabled Card**: Ensure your card is active and not disabled by your Domain Admin. + - **Incorrect Information**: Entering incorrect card details (CVC, ZIP, expiration date) will result in declines. + - **Suspicious Activity**: Transactions may be blocked for unusual or suspicious activity. Check the Expensify Home page to approve unusual merchants. Suspicious spending may prompt a manual due diligence check, during which your cards will be locked. + - **Restricted Country**: Transactions from restricted countries will be declined. {% include faq-begin.md %} ## Can I use Smart Limits with a free Expensify account? 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/articles/expensify-classic/expensify-card/Request-the-Card.md b/docs/articles/expensify-classic/expensify-card/Request-the-Card.md index 724745f458ef..b65c66c986ad 100644 --- a/docs/articles/expensify-classic/expensify-card/Request-the-Card.md +++ b/docs/articles/expensify-classic/expensify-card/Request-the-Card.md @@ -2,45 +2,35 @@ title: Request the Card description: Details on requesting the Expensify Card as an employee --- -# Overview - -Once your organization is approved for the Expensify Visa® Commercial Card, you can request a card! - -This article covers how to request, activate, and replace your physical and virtual Expensify Cards. - -# How to get your first Expensify Card - -An admin in your organization must first enable the Expensify Cards before you can receive a card. After that, an admin may assign you a card by setting a limit. You can think of setting a card limit as “unlocking” access to the card. - -If you haven’t been assigned a limit yet, look for the task on your account's homepage that says, “Ask your admin for the card!” This task allows you to message your admin team to make that request. - -Once you’re assigned a card limit, we’ll notify you via email to let you know you can request a card. A link within the notification email will take you to your account’s homepage, where you can provide your shipping address for the physical card. Enter your address, and we’ll ship the card to arrive within 3-5 business days. - -Once your physical card arrives in the mail, activate it in Expensify by entering the last four digits of the card in the activation task on your account’s homepage. - -# Virtual Card - -Once assigned a limit, a virtual card is available immediately. You can view the virtual card details via **Settings > Account > Credit Card Import > Show Details**. Feel free to begin transacting with the virtual card while your physical card is in transit – your virtual card and physical card share a limit. - -Please note that you must enable two-factor authentication on your account if you want to have the option to dispute transactions made on your virtual card. - -# Notifications - -To stay up-to-date on your card’s limit and spending activity, download the Expensify mobile app and enable push notifications. Your card is connected to your Expensify account, so each transaction on your card will trigger a push notification. We’ll also send you a push notification if we detect potentially fraudulent activity and allow you to confirm your purchase. - -# How to request a replacement Expensify Card - -You can request a new card anytime if your Expensify Card is lost, stolen, or damaged. From your Expensify account on the web, head to **Settings > Account > Credit Card Import** and click **Request a New Card**. Confirm the shipping information, complete the prompts, and your new card will arrive in 2 - 3 business days. - -Selecting the “lost” or “stolen” options will deactivate your current card to prevent potentially fraudulent activity. However, choosing the “damaged” option will leave your current card active so you can use it while the new one is shipped to you. - -If you need to cancel your Expensify Card and cannot access the website or mobile app, call our interactive voice recognition phone service (available 24/7). Call 1-877-751-5848 (US) or +44 808 196 0632 (Internationally). - -It's not possible to order a replacement card over the phone, so, if applicable, you would need to handle this step from your Expensify account. - -# Card Expiration Date - -If you notice that your card expiration date is soon, it's time for a new Expensify card. Expensify will automatically input a notification in your account's Home (Inbox) tab. This notice will ask you to input your address, but this is more if you have changed your address since your card was issued to you. You can ignore it and do nothing; the new Expensify card will ship to your address on file. The new Expensify card will have a new, unique card number and will not be associated with the old one. +To start using the Expensify Card, do the following: +1. **Enable Expensify Cards:** An admin must first enable the cards. Then, an admin can assign you a card by setting a limit, which allows access to the card. +2. **Request the Card:** + - If you haven’t been assigned a limit, look for the task on your account’s homepage that says, “Ask your admin for the card!” Use this task to message your admin team. + - Once you’re assigned a card limit, you’ll receive an email notification. Click the link in the email to provide your shipping address on your account’s homepage. + - Enter your address, and the physical card will be shipped within 3-5 business days. +3. **Activate the Card:** When your physical card arrives, activate it in Expensify by entering the last four digits of the card in the activation task on your homepage. + +### Virtual Cards +Once you've been assigned a limit, a virtual card is available immediately. You can view its details via _**Settings > Account > Credit Card Import > Show Details**_. + +To protect your account and card spend, enable two-factor authentication under _**Settings > Account > Account Details**_. + +### Notifications +- Download the Expensify mobile app and enable push notifications to stay updated on your card’s limit and spending. +- Each transaction triggers a push notification. +- You’ll also get notifications for potentially fraudulent activity, allowing you to confirm or dispute charges. + +## Request a Replacement Expensify Card +### If the card is lost, stolen, or damaged Card: + - Go to _**Settings > Account > Credit Card Import** and click **Request a New Card**_. + - Confirm your shipping information and complete the prompts. The new card will arrive in 2-3 business days. + - Selecting “lost” or “stolen” deactivates your current card to prevent fraud. Choosing “damaged” keeps the current card active until the new one arrives. + - If you can’t access the website or app, call 1-877-751-5848 (US) or +44 808 196 0632 (Internationally) to cancel your card. + +### If the card is expiring +- If your card is about to expire, Expensify will notify you via your account’s Home (Inbox) tab. +- Enter your address if it has changed; otherwise, do nothing, and the new card will ship to your address on file. +- The new card will have a unique number and will not be linked to the old one. {% include faq-begin.md %} diff --git a/docs/articles/expensify-classic/expensify-card/Statements.md b/docs/articles/expensify-classic/expensify-card/Statements.md index 894dfa3d8b9a..eb797f0cee4b 100644 --- a/docs/articles/expensify-classic/expensify-card/Statements.md +++ b/docs/articles/expensify-classic/expensify-card/Statements.md @@ -1,73 +1,62 @@ --- title: — Expensify Card Statements and Settlements -description: Learn how the Expensify Card statement and settlements work! +description: Understand how to access your Expensify Card Statement --- -# Overview -Expensify offers several settlement types and a statement that provides a detailed view of transactions and settlements. We discuss specifics on both below. +## Expensify Card Statements +Expensify offers several settlement types and a detailed statement of transactions and settlements. -# How to use Expensify Visa® Commercial Card Statement and Settlements -## Using the statement -If your domain uses the Expensify Card and you have a validated Business Bank Account, access the Expensify Card statement at Settings > Domains > Company Cards > Reconciliation Tab > Settlements. +### Accessing the Statement +- If your domain uses the Expensify Card and you have a validated Business Bank Account, access the statement at _**Settings > Domains > Company Cards > Reconciliation Tab > Settlements**_. +- The statement shows individual transactions (debits) and their corresponding settlements (credits). -The Expensify Card statement displays individual transactions (debits) and their corresponding settlements (credits). Each Expensify Cardholder has a Digital Card and a Physical Card, which are treated the same in settlement, reconciliation, and exporting to your accounting system. - -Here's a breakdown of crucial information in the statement: -- **Date:** For card payments, it shows the debit date; for card transactions, it displays the purchase date. -- **Entry ID:** This unique ID groups card payments and transactions together. -- **Withdrawn Amount:** This applies to card payments, matching the debited amount from the Business Bank Account. -- **Transaction Amount:** This applies to card transactions, matching the expense purchase amount. -- **User email:** Applies to card transactions, indicating the cardholder's Expensify email address. -- **Transaction ID:** A unique ID for locating transactions and assisting Expensify Support in case of issues. Transaction IDs are handy for reconciling pre-authorizations. To find the original purchase, locate the Transaction ID in the Settlements tab of the reconciliation dashboard, download the settlements as a CSV, and search for the Transaction ID within it. +### Key Information in the Statement +- **Date:** Debit date for card payments; purchase date for transactions. +- **Entry ID:** Unique ID grouping card payments and transactions. +- **Withdrawn Amount:** Amount debited from the Business Bank Account for card payments. +- **Transaction Amount:** Expense purchase amount for card transactions. +- **User Email:** Cardholder’s Expensify email address. +- **Transaction ID:** Unique ID for locating transactions and assisting support. ![Expanded card settlement that shows the various items that make up each card settlement.](https://help.expensify.com/assets/images/ExpensifyHelp_SettlementExpanded.png){:width="100%"} -The Expensify Card statement only shows payments from existing Business Bank Accounts under Settings > Account > Payments > Business Accounts. If a Business Account is deleted, the statement won't contain data for payments from that account. - -## Exporting your statement -When using the Expensify Card, you can export your statement to a CSV with these steps: +**Note:** The statement only includes payments from existing Business Bank Accounts under **Settings > Account > Payments > Business Accounts**. Deleted accounts' payments won't appear. - 1. Login to your account on the web app and click on Settings > Domains > Company Cards. - 2. Click the Reconciliation tab at the top right, then select Settlements. - 3. Enter your desired statement dates using the Start and End fields. - 4. Click Search to access the statement for that period. - 5. You can view the table or select Download to export it as a CSV. +## Exporting Statements +1. Log in to the web app and go to **Settings > Domains > Company Cards**. +2. Click the **Reconciliation** tab and select **Settlements**. +3. Enter the start and end dates for your statement. +4. Click **Search** to view the statement. +5. Click **Download** to export it as a CSV. ![Click the Download CSV button in the middle of the page to export your card settlements.](https://help.expensify.com/assets/images/ExpensifyHelp_SettlementExport.png){:width="100%"} ## Expensify Card Settlement Frequency -Paying your Expensify Card balance is simple with automatic settlement. There are two settlement frequency options: - - **Daily Settlement:** Your Expensify Card balance is paid in full every business day, meaning you’ll see an itemized debit each business day. - - **Monthly Settlement:** Expensify Cards are settled monthly, with your settlement date determined during the card activation process. With monthly, you’ll see only one itemized debit per month. (Available for Plaid-connected bank accounts with no recent negative balance.) +- **Daily Settlement:** Balance paid in full every business day with an itemized debit each day. +- **Monthly Settlement:** Balance settled monthly on a predetermined date with one itemized debit per month (available for Plaid-connected accounts with no recent negative balance). -## How settlement works -Each business day (Monday through Friday, excluding US bank holidays) or on your monthly settlement date, we calculate the total of posted Expensify Card transactions since the last settlement. The settlement amount represents what you must pay to bring your Expensify Card balance back to $0. +## How Settlement Works +- Each business day or on your monthly settlement date, the total of posted transactions is calculated. +- The settlement amount is withdrawn from the Verified Business Bank Account linked to the primary domain admin, resetting your card balance to $0. +- To change your settlement frequency or bank account, go to _**Settings > Domains > [Domain Name] > Company Cards**_, click the **Settings** tab, and select the new options from the dropdown menu. Click **Save** to confirm. -We'll automatically withdraw this settlement amount from the Verified Business Bank Account linked to the primary domain admin. You can set up this bank account in the web app under Settings > Account > Payments > Bank Accounts. +![Change your card settlement account or settlement frequency via the dropdown menus in the middle of the screen.](https://help.expensify.com/assets/images/ExpensifyHelp_CardSettings.png){:width="100%"} -Once the payment is made, your Expensify Card balance will be $0, and the transactions are considered "settled." -To change your settlement frequency or bank account, go to Settings > Domains > [Domain Name] > Company Cards. On the Company Cards page, click the Settings tab, choose a new settlement frequency or account from the dropdown menu, and click Save to confirm the change. +# FAQ -![Change your card settlement account or settlement frequency via the dropdown menus in the middle of the screen.](https://help.expensify.com/assets/images/ExpensifyHelp_CardSettings.png){:width="100%"} +## Can you pay your balance early if you’ve reached your Domain Limit? +- For Monthly Settlement, use the “Settle Now” button to manually initiate settlement. +- For Daily Settlement, balances settle automatically with no additional action required. -# Expensify Card Statement and Settlements FAQs -## Can you pay your balance early if you've reached your Domain Limit? -If you've chosen Monthly Settlement, you can manually initiate settlement using the "Settle Now" button. We'll settle the outstanding balance and then perform settlement again on your selected predetermined monthly settlement date. - -If you opt for Daily Settlement, the Expensify Card statement will automatically settle daily through an automatic withdrawal from your business bank account. No additional action is needed on your part. - ## Will our domain limit change if our Verified Bank Account has a higher balance? -Your domain limit may fluctuate based on your cash balance, spending patterns, and history with Expensify. Suppose you've recently transferred funds to the business bank account linked to Expensify card settlements. In that case, you should expect a change in your domain limit within 24 hours of the transfer (assuming your business bank account is connected through Plaid). - +Domain limits may change based on cash balance, spending patterns, and history with Expensify. If your bank account is connected through Plaid, expect changes within 24 hours of transferring funds. + ## How is the “Amount Owed” figure on the card list calculated? -The amount owed consists of all Expensify Card transactions, both pending and posted, since the last settlement date. The settlement amount withdrawn from your designated Verified Business Bank Account only includes posted transactions. - -Your amount owed decreases when the settlement clears. Any pending transactions that don't post timely will automatically expire, reducing your amount owed. - -## **How do I view all unsettled expenses?** -To view unsettled expenses since the last settlement, use the Reconciliation Dashboard's Expenses tab. Follow these steps: - 1. Note the dates of expenses in your last settlement. - 2. Switch to the Expenses tab on the Reconciliation Dashboard. - 3. Set the start date just after the last settled expenses and the end date to today. - 4. The Imported Total will show the outstanding amount, and you can click through to view individual expenses. +It includes all pending and posted transactions since the last settlement date. The settlement amount withdrawn only includes posted transactions. + +## How do I view all unsettled expenses? +1. Note the dates of expenses in your last settlement. +2. Go to the **Expenses** tab on the Reconciliation Dashboard. +3. Set the start date after the last settled expenses and the end date to today. +4. The **Imported Total** shows the outstanding amount, and you can click to view individual expenses. diff --git a/docs/assets/images/ExpensifyHelp-Invoice-1.png b/docs/assets/images/ExpensifyHelp-Invoice-1.png index e4a042afef82..a6dda9fdca92 100644 Binary files a/docs/assets/images/ExpensifyHelp-Invoice-1.png and b/docs/assets/images/ExpensifyHelp-Invoice-1.png differ diff --git a/docs/assets/images/ExpensifyHelp-QBO-1.png b/docs/assets/images/ExpensifyHelp-QBO-1.png index 2aa80e954f1b..e20a5e4222d0 100644 Binary files a/docs/assets/images/ExpensifyHelp-QBO-1.png and b/docs/assets/images/ExpensifyHelp-QBO-1.png differ diff --git a/docs/assets/images/ExpensifyHelp-QBO-2.png b/docs/assets/images/ExpensifyHelp-QBO-2.png index 23419b86b6aa..66b71b8d8ec8 100644 Binary files a/docs/assets/images/ExpensifyHelp-QBO-2.png and b/docs/assets/images/ExpensifyHelp-QBO-2.png differ diff --git a/docs/assets/images/ExpensifyHelp-QBO-3.png b/docs/assets/images/ExpensifyHelp-QBO-3.png index c612cb760d58..f96550868bbd 100644 Binary files a/docs/assets/images/ExpensifyHelp-QBO-3.png and b/docs/assets/images/ExpensifyHelp-QBO-3.png differ diff --git a/docs/assets/images/ExpensifyHelp-QBO-4.png b/docs/assets/images/ExpensifyHelp-QBO-4.png index 7fbc99503f2e..c7b85a93b04b 100644 Binary files a/docs/assets/images/ExpensifyHelp-QBO-4.png and b/docs/assets/images/ExpensifyHelp-QBO-4.png differ diff --git a/docs/assets/images/ExpensifyHelp-QBO-5.png b/docs/assets/images/ExpensifyHelp-QBO-5.png index 600a5903c05f..99b83b8be2d1 100644 Binary files a/docs/assets/images/ExpensifyHelp-QBO-5.png and b/docs/assets/images/ExpensifyHelp-QBO-5.png differ diff --git a/docs/redirects.csv b/docs/redirects.csv index f2d9a797415b..67ca238c1aed 100644 --- a/docs/redirects.csv +++ b/docs/redirects.csv @@ -203,6 +203,7 @@ https://help.expensify.com/articles/new-expensify/chat/Expensify-Chat-For-Admins https://help.expensify.com/articles/new-expensify/bank-accounts-and-payments/Connect-a-Bank-Account.html,https://help.expensify.com/articles/new-expensify/expenses/Connect-a-Business-Bank-Account https://help.expensify.com/articles/expensify-classic/travel/Coming-Soon,https://help.expensify.com/expensify-classic/hubs/travel/ https://help.expensify.com/articles/new-expensify/expenses/Manually-submit-reports-for-approval,https://help.expensify.com/new-expensify/hubs/expenses/ +https://help.expensify.com/articles/expensify-classic/expensify-card/Auto-Reconciliation,https://help.expensify.com/articles/expensify-classic/expensify-card/Expensify-Card-Reconciliation.md https://help.expensify.com/articles/new-expensify/expenses/Approve-and-pay-expenses,https://help.expensify.com/articles/new-expensify/expenses-&-payments/Approve-and-pay-expenses https://help.expensify.com/articles/new-expensify/expenses/Connect-a-Business-Bank-Account,https://help.expensify.com/articles/new-expensify/expenses-&-payments/Connect-a-Business-Bank-Account https://help.expensify.com/articles/new-expensify/expenses/Create-an-expense,https://help.expensify.com/articles/new-expensify/expenses-&-payments/Create-an-expense diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index ad8cc982e052..25a2d9c67427 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -19,7 +19,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 9.0.3 + 9.0.5 CFBundleSignature ???? CFBundleURLTypes @@ -40,7 +40,7 @@ CFBundleVersion - 9.0.3.5 + 9.0.5.10 FullStory OrgId diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index 8fc553fe8c0c..d4ecead8fcd1 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -15,10 +15,10 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 9.0.3 + 9.0.5 CFBundleSignature ???? CFBundleVersion - 9.0.3.5 + 9.0.5.10 diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index 83e4d904584b..86c4dfbca3fd 100644 --- a/ios/NotificationServiceExtension/Info.plist +++ b/ios/NotificationServiceExtension/Info.plist @@ -11,9 +11,9 @@ CFBundleName $(PRODUCT_NAME) CFBundleShortVersionString - 9.0.3 + 9.0.5 CFBundleVersion - 9.0.3.5 + 9.0.5.10 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 e9cc310d29e9..36cb2639ec4f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "9.0.3-5", + "version": "9.0.5-10", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "9.0.3-5", + "version": "9.0.5-10", "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.56", "react-native-pager-view": "6.2.3", "react-native-pdf": "6.7.3", "react-native-performance": "^5.1.0", @@ -200,7 +200,7 @@ "babel-jest": "29.4.1", "babel-loader": "^9.1.3", "babel-plugin-module-resolver": "^5.0.0", - "babel-plugin-react-compiler": "^0.0.0-experimental-c23de8d-20240515", + "babel-plugin-react-compiler": "0.0.0-experimental-696af53-20240625", "babel-plugin-react-native-web": "^0.18.7", "babel-plugin-transform-class-properties": "^6.24.1", "babel-plugin-transform-remove-console": "^6.9.4", @@ -219,7 +219,7 @@ "eslint-config-prettier": "^9.1.0", "eslint-plugin-jest": "^28.6.0", "eslint-plugin-jsdoc": "^46.2.6", - "eslint-plugin-react-compiler": "^0.0.0-experimental-53bb89e-20240515", + "eslint-plugin-react-compiler": "0.0.0-experimental-0998c1e-20240625", "eslint-plugin-react-native-a11y": "^3.3.0", "eslint-plugin-storybook": "^0.8.0", "eslint-plugin-testing-library": "^6.2.2", @@ -236,6 +236,7 @@ "portfinder": "^1.0.28", "prettier": "^2.8.8", "pusher-js-mock": "^0.3.3", + "react-compiler-healthcheck": "^0.0.0-experimental-b130d5f-20240625", "react-is": "^18.3.1", "react-native-clean-project": "^4.0.0-alpha4.0", "react-test-renderer": "18.2.0", @@ -248,7 +249,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", @@ -20331,9 +20332,9 @@ } }, "node_modules/babel-plugin-react-compiler": { - "version": "0.0.0-experimental-c23de8d-20240515", - "resolved": "https://registry.npmjs.org/babel-plugin-react-compiler/-/babel-plugin-react-compiler-0.0.0-experimental-c23de8d-20240515.tgz", - "integrity": "sha512-0XN2gmpT55QtAz5n7d5g91y1AuO9tRhWBaLgCRyc4ExHrlr7+LfxW+YTb3mOwxngkkiggwM8HyYsaEK9MqhnlQ==", + "version": "0.0.0-experimental-696af53-20240625", + "resolved": "https://registry.npmjs.org/babel-plugin-react-compiler/-/babel-plugin-react-compiler-0.0.0-experimental-696af53-20240625.tgz", + "integrity": "sha512-OUDKms8qmcm5bX0D+sJWC1YcKcd7AZ2aJ7eY6gkR+Xr7PDfkXLbqAld4Qs9B0ntjVbUMEtW/PjlQrxDtY4raHg==", "dev": true, "dependencies": { "@babel/generator": "7.2.0", @@ -25297,9 +25298,9 @@ } }, "node_modules/eslint-plugin-react-compiler": { - "version": "0.0.0-experimental-53bb89e-20240515", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-compiler/-/eslint-plugin-react-compiler-0.0.0-experimental-53bb89e-20240515.tgz", - "integrity": "sha512-L3HV9qja1dnClRlR9aaWEJeJoGPH9cgjKq0sYqIOOH9uyWdVMH9CudsFr6yLva7dj05FpLZkiIaRSZJ3P/v6yQ==", + "version": "0.0.0-experimental-0998c1e-20240625", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-compiler/-/eslint-plugin-react-compiler-0.0.0-experimental-0998c1e-20240625.tgz", + "integrity": "sha512-npq2RomExoQI3jETs4OrifaygyJYgOcX/q74Q9OC7GmffLh5zSJaQpzjs2fi61NMNkJyIvTBD0C6sKTGGcetOw==", "dev": true, "dependencies": { "@babel/core": "^7.24.4", @@ -36758,6 +36759,98 @@ "react-dom": ">=16.8.0" } }, + "node_modules/react-compiler-healthcheck": { + "version": "0.0.0-experimental-b130d5f-20240625", + "resolved": "https://registry.npmjs.org/react-compiler-healthcheck/-/react-compiler-healthcheck-0.0.0-experimental-b130d5f-20240625.tgz", + "integrity": "sha512-vf3Ipg+f19yOYQeRP938e5jWNEpwR6EX5pwBZdJUF9rt11vJ3ckgUVcF5qGWUU/7DB0N9MH1koBqwqMYabrBiQ==", + "dev": true, + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "chalk": "4", + "fast-glob": "^3.3.2", + "ora": "5.4.1", + "yargs": "^17.7.2", + "zod": "^3.22.4", + "zod-validation-error": "^3.0.3" + }, + "bin": { + "react-compiler-healthcheck": "dist/index.js" + }, + "engines": { + "node": "^14.17.0 || ^16.0.0 || >= 18.0.0" + } + }, + "node_modules/react-compiler-healthcheck/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/react-compiler-healthcheck/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/react-compiler-healthcheck/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/react-compiler-healthcheck/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/react-compiler-healthcheck/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/react-compiler-healthcheck/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/react-compiler-runtime": { "resolved": "lib/react-compiler-runtime", "link": true @@ -37277,9 +37370,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.56", + "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-2.0.56.tgz", + "integrity": "sha512-3rn1+J4tli9zPS9w5x6tOAUz01wVHkiTFgtHoIwjD7HdLUO/9nk6H8JX6Oqb9Vzq2XQOSavUFRepIHnGvzNtgg==", "dependencies": { "ascii-table": "0.0.9", "fast-equals": "^4.0.3", @@ -41840,9 +41933,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 e5e8f8fa8e60..6cb85495e21b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "9.0.3-5", + "version": "9.0.5-10", "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.", @@ -60,7 +60,8 @@ "workflow-test": "./workflow_tests/scripts/runWorkflowTests.sh", "workflow-test:generate": "ts-node workflow_tests/utils/preGenerateTest.ts", "setup-https": "mkcert -install && mkcert -cert-file config/webpack/certificate.pem -key-file config/webpack/key.pem dev.new.expensify.com localhost 127.0.0.1", - "e2e-test-runner-build": "ncc build tests/e2e/testRunner.ts -o tests/e2e/dist/" + "e2e-test-runner-build": "ncc build tests/e2e/testRunner.ts -o tests/e2e/dist/", + "react-compiler-healthcheck": "react-compiler-healthcheck --verbose" }, "dependencies": { "@babel/plugin-proposal-private-methods": "^7.18.6", @@ -155,7 +156,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.56", "react-native-pager-view": "6.2.3", "react-native-pdf": "6.7.3", "react-native-performance": "^5.1.0", @@ -253,7 +254,7 @@ "babel-jest": "29.4.1", "babel-loader": "^9.1.3", "babel-plugin-module-resolver": "^5.0.0", - "babel-plugin-react-compiler": "^0.0.0-experimental-c23de8d-20240515", + "babel-plugin-react-compiler": "0.0.0-experimental-696af53-20240625", "babel-plugin-react-native-web": "^0.18.7", "babel-plugin-transform-class-properties": "^6.24.1", "babel-plugin-transform-remove-console": "^6.9.4", @@ -272,7 +273,7 @@ "eslint-config-prettier": "^9.1.0", "eslint-plugin-jest": "^28.6.0", "eslint-plugin-jsdoc": "^46.2.6", - "eslint-plugin-react-compiler": "^0.0.0-experimental-53bb89e-20240515", + "eslint-plugin-react-compiler": "0.0.0-experimental-0998c1e-20240625", "eslint-plugin-react-native-a11y": "^3.3.0", "eslint-plugin-storybook": "^0.8.0", "eslint-plugin-testing-library": "^6.2.2", @@ -289,6 +290,7 @@ "portfinder": "^1.0.28", "prettier": "^2.8.8", "pusher-js-mock": "^0.3.3", + "react-compiler-healthcheck": "^0.0.0-experimental-b130d5f-20240625", "react-is": "^18.3.1", "react-native-clean-project": "^4.0.0-alpha4.0", "react-test-renderer": "18.2.0", @@ -301,7 +303,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/@expensify+react-native-live-markdown+0.1.85.patch b/patches/@expensify+react-native-live-markdown+0.1.88.patch similarity index 100% rename from patches/@expensify+react-native-live-markdown+0.1.85.patch rename to patches/@expensify+react-native-live-markdown+0.1.88.patch 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/patches/eslint-plugin-react-compiler+0.0.0-experimental-53bb89e-20240515.patch b/patches/eslint-plugin-react-compiler+0.0.0-experimental-53bb89e-20240515.patch deleted file mode 100644 index f81f70944dd2..000000000000 --- a/patches/eslint-plugin-react-compiler+0.0.0-experimental-53bb89e-20240515.patch +++ /dev/null @@ -1,13 +0,0 @@ -diff --git a/node_modules/eslint-plugin-react-compiler/dist/index.js b/node_modules/eslint-plugin-react-compiler/dist/index.js -index a0f47a7..f649250 100644 ---- a/node_modules/eslint-plugin-react-compiler/dist/index.js -+++ b/node_modules/eslint-plugin-react-compiler/dist/index.js -@@ -69108,7 +69108,7 @@ const rule = { - return false; - } - let babelAST; -- if (context.filename.endsWith(".tsx") || context.filename.endsWith(".ts")) { -+ if (filename.endsWith(".tsx") || filename.endsWith(".ts")) { - try { - const { parse: babelParse } = require("@babel/parser"); - babelAST = babelParse(sourceCode, { diff --git a/patches/react-compiler-healthcheck+0.0.0-experimental-b130d5f-20240625.patch b/patches/react-compiler-healthcheck+0.0.0-experimental-b130d5f-20240625.patch new file mode 100644 index 000000000000..d7c02701a636 --- /dev/null +++ b/patches/react-compiler-healthcheck+0.0.0-experimental-b130d5f-20240625.patch @@ -0,0 +1,90 @@ +diff --git a/node_modules/react-compiler-healthcheck/dist/index.js b/node_modules/react-compiler-healthcheck/dist/index.js +index b427385..4bf23db 100755 +--- a/node_modules/react-compiler-healthcheck/dist/index.js ++++ b/node_modules/react-compiler-healthcheck/dist/index.js +@@ -69154,7 +69154,7 @@ var reactCompilerCheck = { + compile(source, path); + } + }, +- report() { ++ report(verbose) { + const totalComponents = + SucessfulCompilation.length + + countUniqueLocInEvents(OtherFailures) + +@@ -69164,6 +69164,50 @@ var reactCompilerCheck = { + `Successfully compiled ${SucessfulCompilation.length} out of ${totalComponents} components.` + ) + ); ++ ++ if (verbose) { ++ for (const compilation of [...SucessfulCompilation, ...ActionableFailures, ...OtherFailures]) { ++ const filename = compilation.fnLoc?.filename; ++ ++ if (compilation.kind === "CompileSuccess") { ++ const name = compilation.fnName; ++ const isHook = name?.startsWith('use'); ++ ++ if (name) { ++ console.log( ++ chalk.green( ++ `Successfully compiled ${isHook ? "hook" : "component" } [${name}](${filename})` ++ ) ++ ); ++ } else { ++ console.log(chalk.green(`Successfully compiled ${compilation.fnLoc?.filename}`)); ++ } ++ } ++ ++ if (compilation.kind === "CompileError") { ++ const { reason, severity, loc } = compilation.detail; ++ ++ const lnNo = loc.start?.line; ++ const colNo = loc.start?.column; ++ ++ const isTodo = severity === ErrorSeverity.Todo; ++ ++ console.log( ++ chalk[isTodo ? 'yellow' : 'red']( ++ `Failed to compile ${ ++ filename ++ }${ ++ lnNo !== undefined ? `:${lnNo}${ ++ colNo !== undefined ? `:${colNo}` : "" ++ }.` : "" ++ }` ++ ), ++ chalk[isTodo ? 'yellow' : 'red'](reason? `Reason: ${reason}` : "") ++ ); ++ console.log("\n"); ++ } ++ } ++ } + }, + }; + const JsFileExtensionRE = /(js|ts|jsx|tsx)$/; +@@ -69200,9 +69244,16 @@ function main() { + type: "string", + default: "**/+(*.{js,mjs,jsx,ts,tsx}|package.json)", + }) ++ .option('verbose', { ++ description: 'run with verbose logging', ++ type: 'boolean', ++ default: false, ++ alias: 'v', ++ }) + .parseSync(); + const spinner = ora("Checking").start(); + let src = argv.src; ++ let verbose = argv.verbose; + const globOptions = { + onlyFiles: true, + ignore: [ +@@ -69222,7 +69273,7 @@ function main() { + libraryCompatCheck.run(source, path); + } + spinner.stop(); +- reactCompilerCheck.report(); ++ reactCompilerCheck.report(verbose); + strictModeCheck.report(); + libraryCompatCheck.report(); + }); diff --git a/patches/react-native-keyboard-controller+1.12.2.patch.patch b/patches/react-native-keyboard-controller+1.12.2.patch similarity index 100% rename from patches/react-native-keyboard-controller+1.12.2.patch.patch rename to patches/react-native-keyboard-controller+1.12.2.patch diff --git a/patches/react-native-reanimated+3.7.2+001+fix-boost-dependency.patch b/patches/react-native-reanimated+3.8.1+001+fix-boost-dependency.patch similarity index 100% rename from patches/react-native-reanimated+3.7.2+001+fix-boost-dependency.patch rename to patches/react-native-reanimated+3.8.1+001+fix-boost-dependency.patch diff --git a/patches/react-native-reanimated+3.7.2+002+copy-state.patch b/patches/react-native-reanimated+3.8.1+002+copy-state.patch similarity index 100% rename from patches/react-native-reanimated+3.7.2+002+copy-state.patch rename to patches/react-native-reanimated+3.8.1+002+copy-state.patch diff --git a/patches/react-native-reanimated+3.7.2.patch b/patches/react-native-reanimated+3.8.1.patch similarity index 100% rename from patches/react-native-reanimated+3.7.2.patch rename to patches/react-native-reanimated+3.8.1.patch diff --git a/scripts/applyPatches.sh b/scripts/applyPatches.sh new file mode 100755 index 000000000000..a4be88984561 --- /dev/null +++ b/scripts/applyPatches.sh @@ -0,0 +1,56 @@ +#!/bin/bash + +# This script is a simple wrapper around patch-package that fails if any errors or warnings are detected. +# This is useful because patch-package does not fail on errors or warnings by default, +# which means that broken patches are easy to miss, and leads to developer frustration and wasted time. + +SCRIPTS_DIR=$(dirname "${BASH_SOURCE[0]}") +source "$SCRIPTS_DIR/shellUtils.sh" + +# Wrapper to run patch-package. +# We use `script` to preserve colorization when the output of patch-package is piped to tee +# and we provide /dev/null to discard the output rather than sending it to a file +# `script` has different syntax on macOS vs linux, so that's why we need a wrapper function +function patchPackage { + OS="$(uname)" + if [[ "$OS" == "Darwin" ]]; then + # macOS + script -q /dev/null npx patch-package --error-on-fail + elif [[ "$OS" == "Linux" ]]; then + # Ubuntu/Linux + script -q -c "npx patch-package --error-on-fail" /dev/null + else + error "Unsupported OS: $OS" + fi +} + +# Run patch-package and capture its output and exit code, while still displaying the original output to the terminal +# (we use `script -q /dev/null` to preserve colorization in the output) +TEMP_OUTPUT="$(mktemp)" +patchPackage 2>&1 | tee "$TEMP_OUTPUT" +EXIT_CODE=${PIPESTATUS[0]} +OUTPUT="$(cat "$TEMP_OUTPUT")" +rm -f "$TEMP_OUTPUT" + +# Check if the output contains a warning message +echo "$OUTPUT" | grep -q "Warning:" +WARNING_FOUND=$? + +printf "\n"; + +# Determine the final exit code +if [ "$EXIT_CODE" -eq 0 ]; then + if [ $WARNING_FOUND -eq 0 ]; then + # patch-package succeeded but warning was found + error "It looks like you upgraded a dependency without upgrading the patch. Please review the patch, determine if it's still needed, and port it to the new version of the dependency." + exit 1 + else + # patch-package succeeded and no warning was found + success "patch-package succeeded without errors or warnings" + exit 0 + fi +else + # patch-package failed + error "patch-package failed to apply a patch" + exit "$EXIT_CODE" +fi diff --git a/scripts/postInstall.sh b/scripts/postInstall.sh index 339fdf25cb10..782c8ef5822c 100755 --- a/scripts/postInstall.sh +++ b/scripts/postInstall.sh @@ -1,11 +1,14 @@ #!/bin/bash +# Exit immediately if any command exits with a non-zero status +set -e + # Go to project root ROOT_DIR=$(dirname "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &> /dev/null && pwd)") cd "$ROOT_DIR" || exit 1 -# Run patch-package -npx patch-package +# Apply packages using patch-package +scripts/applyPatches.sh # Install node_modules in subpackages, unless we're in a CI/CD environment, # where the node_modules for subpackages are cached separately. diff --git a/src/CONST.ts b/src/CONST.ts index d74474978c2b..6d81abc37a85 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -74,6 +74,10 @@ const onboardingChoices = { type OnboardingPurposeType = ValueOf; const CONST = { + RECENT_WAYPOINTS_NUMBER: 20, + 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 @@ -365,6 +369,7 @@ const CONST = { REPORT_FIELDS_FEATURE: 'reportFieldsFeature', WORKSPACE_FEEDS: 'workspaceFeeds', NETSUITE_USA_TAX: 'netsuiteUsaTax', + INTACCT_ON_NEW_EXPENSIFY: 'intacctOnNewExpensify', }, BUTTON_STATES: { DEFAULT: 'default', @@ -715,7 +720,7 @@ const CONST = { TASK_EDITED: 'TASKEDITED', TASK_REOPENED: 'TASKREOPENED', TRIPPREVIEW: 'TRIPPREVIEW', - UNAPPROVED: 'UNAPPROVED', // OldDot Action + UNAPPROVED: 'UNAPPROVED', UNHOLD: 'UNHOLD', UNSHARE: 'UNSHARE', // OldDot Action UPDATE_GROUP_CHAT_MEMBER_ROLE: 'UPDATEGROUPCHATMEMBERROLE', @@ -786,6 +791,7 @@ const CONST = { UPDATE_TIME_ENABLED: 'POLICYCHANGELOG_UPDATE_TIME_ENABLED', UPDATE_TIME_RATE: 'POLICYCHANGELOG_UPDATE_TIME_RATE', LEAVE_POLICY: 'POLICYCHANGELOG_LEAVE_POLICY', + CORPORATE_UPGRADE: 'POLICYCHANGELOG_CORPORATE_UPGRADE', }, ROOM_CHANGE_LOG: { INVITE_TO_ROOM: 'INVITETOROOM', @@ -1288,6 +1294,7 @@ const CONST = { REPORT_FIELD: 'REPORT_FIELD', NOT_IMPORTED: 'NOT_IMPORTED', IMPORTED: 'IMPORTED', + NETSUITE_DEFAULT: 'NETSUITE_DEFAULT', }, QUICKBOOKS_ONLINE: 'quickbooksOnline', @@ -1338,18 +1345,59 @@ const CONST = { }, }, + SAGE_INTACCT_CONFIG: { + EXPORT: 'export', + EXPORT_DATE: 'exportDate', + NON_REIMBURSABLE_CREDIT_CARD_VENDOR: 'nonReimbursableCreditCardChargeDefaultVendor', + NON_REIMBURSABLE_VENDOR: 'nonReimbursableVendor', + REIMBURSABLE_VENDOR: 'reimbursableExpenseReportDefaultVendor', + NON_REIMBURSABLE_ACCOUNT: 'nonReimbursableAccount', + NON_REIMBURSABLE: 'nonReimbursable', + EXPORTER: 'exporter', + REIMBURSABLE: 'reimbursable', + AUTO_SYNC: 'autoSync', + AUTO_SYNC_ENABLED: 'enabled', + IMPORT_EMPLOYEES: 'importEmployees', + APPROVAL_MODE: 'approvalMode', + SYNC: 'sync', + SYNC_REIMBURSED_REPORTS: 'syncReimbursedReports', + REIMBURSEMENT_ACCOUNT_ID: 'reimbursementAccountID', + }, + + SAGE_INTACCT: { + APPROVAL_MODE: { + APPROVAL_MANUAL: 'APPROVAL_MANUAL', + }, + }, + QUICKBOOKS_REIMBURSABLE_ACCOUNT_TYPE: { VENDOR_BILL: 'bill', CHECK: 'check', JOURNAL_ENTRY: 'journal_entry', }, + SAGE_INTACCT_REIMBURSABLE_EXPENSE_TYPE: { + EXPENSE_REPORT: 'EXPENSE_REPORT', + VENDOR_BILL: 'VENDOR_BILL', + }, + + SAGE_INTACCT_NON_REIMBURSABLE_EXPENSE_TYPE: { + CREDIT_CARD_CHARGE: 'CREDIT_CARD_CHARGE', + VENDOR_BILL: 'VENDOR_BILL', + }, + XERO_EXPORT_DATE: { LAST_EXPENSE: 'LAST_EXPENSE', REPORT_EXPORTED: 'REPORT_EXPORTED', REPORT_SUBMITTED: 'REPORT_SUBMITTED', }, + SAGE_INTACCT_EXPORT_DATE: { + LAST_EXPENSE: 'LAST_EXPENSE', + EXPORTED: 'EXPORTED', + SUBMITTED: 'SUBMITTED', + }, + NETSUITE_CONFIG: { SUBSIDIARY: 'subsidiary', EXPORTER: 'exporter', @@ -1367,6 +1415,40 @@ const CONST = { PROVINCIAL_TAX_POSTING_ACCOUNT: 'provincialTaxPostingAccount', ALLOW_FOREIGN_CURRENCY: 'allowForeignCurrency', EXPORT_TO_NEXT_OPEN_PERIOD: 'exportToNextOpenPeriod', + 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'], + CUSTOM_FORM_ID_TYPE: { + REIMBURSABLE: 'reimbursable', + NON_REIMBURSABLE: 'nonReimbursable', + }, + 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', + }, + }, }, NETSUITE_EXPORT_DATE: { @@ -1381,6 +1463,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', @@ -1396,6 +1484,38 @@ 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_ACCOUNT_TYPE: { + ACCOUNTS_PAYABLE: '_accountsPayable', + ACCOUNTS_RECEIVABLE: '_accountsReceivable', + OTHER_CURRENT_LIABILITY: '_otherCurrentLiability', + CREDIT_CARD: '_creditCard', + BANK: '_bank', + OTHER_CURRENT_ASSET: '_otherCurrentAsset', + LONG_TERM_LIABILITY: '_longTermLiability', + EXPENSE: '_expense', + }, + + NETSUITE_APPROVAL_ACCOUNT_DEFAULT: 'APPROVAL_ACCOUNT_DEFAULT', + /** * Countries where tax setting is permitted (Strings are in the format of Netsuite's Country type/enum) * @@ -1842,6 +1962,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', @@ -1852,21 +1977,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', @@ -1897,11 +2007,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', @@ -1985,6 +2090,9 @@ const CONST = { PAID: 'paid', ADMIN: 'admin', }, + DEFAULT_MAX_EXPENSE_AGE: 90, + DEFAULT_MAX_EXPENSE_AMOUNT: 200000, + DEFAULT_MAX_AMOUNT_NO_RECEIPT: 2500, }, CUSTOM_UNITS: { @@ -2063,6 +2171,10 @@ const CONST = { CARD_NAME: 'CardName', CONFIRMATION: 'Confirmation', }, + CARD_TYPE: { + PHYSICAL: 'physical', + VIRTUAL: 'virtual', + }, }, AVATAR_ROW_SIZE: { DEFAULT: 4, @@ -2249,6 +2361,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, @@ -2283,6 +2396,7 @@ const CONST = { PRIVATE_NOTES: 'privateNotes', DELETE: 'delete', MARK_AS_INCOMPLETE: 'markAsIncomplete', + UNAPPROVE: 'unapprove', }, EDIT_REQUEST_FIELD: { AMOUNT: 'amount', @@ -3878,6 +3992,7 @@ const CONST = { TAX_REQUIRED: 'taxRequired', HOLD: 'hold', }, + REVIEW_DUPLICATES_ORDER: ['merchant', 'category', 'tag', 'description', 'taxCode', 'billable', 'reimbursable'], /** Context menu types */ CONTEXT_MENU_TYPES: { @@ -4071,13 +4186,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' + @@ -4088,13 +4203,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' + @@ -4105,13 +4220,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' + @@ -5022,6 +5137,14 @@ const CONST = { ACTION: 'action', TAX_AMOUNT: 'taxAmount', }, + BULK_ACTION_TYPES: { + DELETE: 'delete', + HOLD: 'hold', + UNHOLD: 'unhold', + SUBMIT: 'submit', + APPROVE: 'approve', + PAY: 'pay', + }, }, REFERRER: { @@ -5030,10 +5153,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', }, @@ -5057,7 +5182,28 @@ const CONST = { }, }, + WORKSPACE_CARDS_LIST_LABEL_TYPE: { + CURRENT_BALANCE: 'currentBalance', + REMAINING_LIMIT: 'remainingLimit', + CASH_BACK: 'cashBack', + }, + EXCLUDE_FROM_LAST_VISITED_PATH: [SCREENS.NOT_FOUND, SCREENS.SAML_SIGN_IN, SCREENS.VALIDATE_LOGIN] as string[], + UPGRADE_FEATURE_INTRO_MAPPING: [ + { + id: 'reportFields', + alias: 'report-fields', + name: 'Report Fields', + title: 'workspace.upgrade.reportFields.title', + description: 'workspace.upgrade.reportFields.description', + icon: 'Pencil', + }, + ], + REPORT_FIELD_TYPES: { + TEXT: 'text', + DATE: 'date', + LIST: 'dropdown', + }, } as const; type Country = keyof typeof CONST.ALL_COUNTRIES; diff --git a/src/Expensify.tsx b/src/Expensify.tsx index bfe4db13d9c4..f96c51961acc 100644 --- a/src/Expensify.tsx +++ b/src/Expensify.tsx @@ -208,7 +208,7 @@ function Expensify({ } appStateChangeListener.current.remove(); }; - // eslint-disable-next-line react-hooks/exhaustive-deps -- we don't want this effect to run again + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps -- we don't want this effect to run again }, []); // This is being done since we want to play sound even when iOS device is on silent mode, to align with other platforms. diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 8ec415442041..d5d3ac23a514 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_', @@ -427,6 +433,15 @@ const ONYXKEYS = { // Shared NVPs /** Collection of objects where each object represents the owner of the workspace that is past due billing AND the user is a member of. */ SHARED_NVP_PRIVATE_USER_BILLING_GRACE_PERIOD_END: 'sharedNVP_private_billingGracePeriodEnd_', + + /** Expensify cards settings */ + SHARED_NVP_PRIVATE_EXPENSIFY_CARD_SETTINGS: 'sharedNVP_private_expensifyCardSettings_', + + /** + * Stores the card list for a given fundID and feed in the format: card__ + * So for example: card_12345_Expensify Card + */ + WORKSPACE_CARDS_LIST: 'card_', }, /** List of Form ids */ @@ -445,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: 'workspaceReportFieldForm', + WORKSPACE_REPORT_FIELDS_FORM_DRAFT: 'workspaceReportFieldFormDraft', POLICY_CREATE_DISTANCE_RATE_FORM: 'policyCreateDistanceRateForm', POLICY_CREATE_DISTANCE_RATE_FORM_DRAFT: 'policyCreateDistanceRateFormDraft', POLICY_DISTANCE_RATE_EDIT_FORM: 'policyDistanceRateEditForm', @@ -539,10 +556,14 @@ const ONYXKEYS = { NEW_CHAT_NAME_FORM_DRAFT: 'newChatNameFormDraft', SUBSCRIPTION_SIZE_FORM: 'subscriptionSizeForm', SUBSCRIPTION_SIZE_FORM_DRAFT: 'subscriptionSizeFormDraft', - ISSUE_NEW_EXPENSIFY_CARD_FORM: 'issueNewExpensifyCardForm', - ISSUE_NEW_EXPENSIFY_CARD_FORM_DRAFT: 'issueNewExpensifyCardFormDraft', + ISSUE_NEW_EXPENSIFY_CARD_FORM: 'issueNewExpensifyCard', + ISSUE_NEW_EXPENSIFY_CARD_FORM_DRAFT: 'issueNewExpensifyCardDraft', SAGE_INTACCT_CREDENTIALS_FORM: 'sageIntacctCredentialsForm', SAGE_INTACCT_CREDENTIALS_FORM_DRAFT: 'sageIntacctCredentialsFormDraft', + NETSUITE_TOKEN_INPUT_FORM: 'netsuiteTokenInputForm', + NETSUITE_TOKEN_INPUT_FORM_DRAFT: 'netsuiteTokenInputFormDraft', + NETSUITE_CUSTOM_FORM_ID_FORM: 'netsuiteCustomFormIDForm', + NETSUITE_CUSTOM_FORM_ID_FORM_DRAFT: 'netsuiteCustomFormIDFormDraft', }, } as const; @@ -555,6 +576,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.WorkspaceReportFieldForm; [ONYXKEYS.FORMS.CLOSE_ACCOUNT_FORM]: FormTypes.CloseAccountForm; [ONYXKEYS.FORMS.PROFILE_SETTINGS_FORM]: FormTypes.ProfileSettingsForm; [ONYXKEYS.FORMS.DISPLAY_NAME_FORM]: FormTypes.DisplayNameForm; @@ -605,6 +627,8 @@ 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; + [ONYXKEYS.FORMS.NETSUITE_CUSTOM_FORM_ID_FORM]: FormTypes.NetSuiteCustomFormIDForm; }; type OnyxFormDraftValuesMapping = { @@ -629,6 +653,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; @@ -650,6 +675,8 @@ type OnyxCollectionValuesMapping = { [ONYXKEYS.COLLECTION.POLICY_CONNECTION_SYNC_PROGRESS]: OnyxTypes.PolicyConnectionSyncProgress; [ONYXKEYS.COLLECTION.SNAPSHOT]: OnyxTypes.SearchResults; [ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_USER_BILLING_GRACE_PERIOD_END]: OnyxTypes.BillingGraceEndPeriod; + [ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_EXPENSIFY_CARD_SETTINGS]: OnyxTypes.ExpensifyCardSettings; + [ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST]: OnyxTypes.WorkspaceCardsList; }; type OnyxValuesMapping = { @@ -770,6 +797,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; @@ -788,6 +816,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. */ @@ -795,4 +824,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 1798b79bde0f..06f6291d2bee 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -1,4 +1,4 @@ -import type {ValueOf} from 'type-fest'; +import type {TupleToUnion, ValueOf} from 'type-fest'; import type CONST from './CONST'; import type {IOUAction, IOUType} from './CONST'; import type {IOURequestType} from './libs/actions/IOU'; @@ -678,6 +678,10 @@ const ROUTES = { route: 'settings/workspaces/:policyID/categories/:categoryName', getRoute: (policyID: string, categoryName: string) => `settings/workspaces/${policyID}/categories/${encodeURIComponent(categoryName)}` as const, }, + WORKSPACE_UPGRADE: { + route: 'settings/workspaces/:policyID/upgrade/:featureName', + getRoute: (policyID: string, featureName: string) => `settings/workspaces/${policyID}/upgrade/${encodeURIComponent(featureName)}` as const, + }, WORKSPACE_CATEGORIES_SETTINGS: { route: 'settings/workspaces/:policyID/categories/settings', getRoute: (policyID: string) => `settings/workspaces/${policyID}/categories/settings` as const, @@ -783,21 +787,47 @@ const ROUTES = { route: 'settings/workspaces/:policyID/reportFields', getRoute: (policyID: string) => `settings/workspaces/${policyID}/reportFields` as const, }, - // TODO: uncomment after development is done - // WORKSPACE_EXPENSIFY_CARD_ISSUE_NEW: { - // route: 'settings/workspaces/:policyID/expensify-card/issues-new', - // getRoute: (policyID: string) => `settings/workspaces/${policyID}/expensify-card/issue-new` as const, - // }, - // TODO: remove after development is done - this one is for testing purposes - WORKSPACE_EXPENSIFY_CARD_ISSUE_NEW: 'settings/workspaces/expensify-card/issue-new', - WORKSPACE_DISTANCE_RATES: { - route: 'settings/workspaces/:policyID/distance-rates', - getRoute: (policyID: string) => `settings/workspaces/${policyID}/distance-rates` 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_SETTINGS: { + route: 'settings/workspaces/:policyID/reportField/:reportFieldID/edit', + getRoute: (policyID: string, reportFieldID: string) => `settings/workspaces/${policyID}/reportField/${encodeURIComponent(reportFieldID)}/edit` as const, + }, + WORKSPACE_REPORT_FIELD_LIST_VALUES: { + route: 'settings/workspaces/:policyID/reportField/listValues/:reportFieldID?', + getRoute: (policyID: string, reportFieldID?: string) => `settings/workspaces/${policyID}/reportField/listValues/${encodeURIComponent(reportFieldID ?? '')}` as const, + }, + WORKSPACE_REPORT_FIELD_ADD_VALUE: { + route: 'settings/workspaces/:policyID/reportField/addValue/:reportFieldID?', + getRoute: (policyID: string, reportFieldID?: string) => `settings/workspaces/${policyID}/reportField/addValue/${encodeURIComponent(reportFieldID ?? '')}` as const, + }, + WORKSPACE_REPORT_FIELD_VALUE_SETTINGS: { + route: 'settings/workspaces/:policyID/reportField/:valueIndex/:reportFieldID?', + getRoute: (policyID: string, valueIndex: number, reportFieldID?: string) => + `settings/workspaces/${policyID}/reportField/${valueIndex}/${encodeURIComponent(reportFieldID ?? '')}` as const, + }, + WORKSPACE_REPORT_FIELD_EDIT_VALUE: { + route: 'settings/workspaces/:policyID/reportField/new/:valueIndex/edit', + getRoute: (policyID: string, valueIndex: number) => `settings/workspaces/${policyID}/reportField/new/${valueIndex}/edit` as const, + }, + WORKSPACE_EDIT_REPORT_FIELD_INITIAL_VALUE: { + route: 'settings/workspaces/:policyID/reportField/:reportFieldID/edit/initialValue', + getRoute: (policyID: string, reportFieldID: string) => `settings/workspaces/${policyID}/reportField/${encodeURIComponent(reportFieldID)}/edit/initialValue` as const, }, WORKSPACE_EXPENSIFY_CARD: { route: 'settings/workspaces/:policyID/expensify-card', getRoute: (policyID: string) => `settings/workspaces/${policyID}/expensify-card` as const, }, + WORKSPACE_EXPENSIFY_CARD_ISSUE_NEW: { + route: 'settings/workspaces/:policyID/expensify-card/issue-new', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/expensify-card/issue-new` as const, + }, + WORKSPACE_DISTANCE_RATES: { + route: 'settings/workspaces/:policyID/distance-rates', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/distance-rates` as const, + }, WORKSPACE_CREATE_DISTANCE_RATE: { route: 'settings/workspaces/:policyID/distance-rates/new', getRoute: (policyID: string) => `settings/workspaces/${policyID}/distance-rates/new` as const, @@ -846,6 +876,34 @@ const ROUTES = { route: 'r/:threadReportID/duplicates/review', getRoute: (threadReportID: string) => `r/${threadReportID}/duplicates/review` as const, }, + TRANSACTION_DUPLICATE_REVIEW_MERCHANT_PAGE: { + route: 'r/:threadReportID/duplicates/review/merchant', + getRoute: (threadReportID: string) => `r/${threadReportID}/duplicates/review/merchant` as const, + }, + TRANSACTION_DUPLICATE_REVIEW_CATEGORY_PAGE: { + route: 'r/:threadReportID/duplicates/review/category', + getRoute: (threadReportID: string) => `r/${threadReportID}/duplicates/review/category` as const, + }, + TRANSACTION_DUPLICATE_REVIEW_TAG_PAGE: { + route: 'r/:threadReportID/duplicates/review/tag', + getRoute: (threadReportID: string) => `r/${threadReportID}/duplicates/review/tag` as const, + }, + TRANSACTION_DUPLICATE_REVIEW_TAX_CODE_PAGE: { + route: 'r/:threadReportID/duplicates/review/tax-code', + getRoute: (threadReportID: string) => `r/${threadReportID}/duplicates/review/tax-code` as const, + }, + TRANSACTION_DUPLICATE_REVIEW_DESCRIPTION_PAGE: { + route: 'r/:threadReportID/duplicates/confirm', + getRoute: (threadReportID: string) => `r/${threadReportID}/duplicates/confirm` as const, + }, + TRANSACTION_DUPLICATE_REVIEW_REIMBURSABLE_PAGE: { + route: 'r/:threadReportID/duplicates/review/reimbursable', + getRoute: (threadReportID: string) => `r/${threadReportID}/duplicates/review/reimbursable` as const, + }, + TRANSACTION_DUPLICATE_REVIEW_BILLABLE_PAGE: { + route: 'r/:threadReportID/duplicates/review/billable', + getRoute: (threadReportID: string) => `r/${threadReportID}/duplicates/review/billable` as const, + }, POLICY_ACCOUNTING_XERO_IMPORT: { route: 'settings/workspaces/:policyID/accounting/xero/import', @@ -937,8 +995,29 @@ const ROUTES = { getRoute: (policyID: string) => `restricted-action/workspace/${policyID}` as const, }, POLICY_ACCOUNTING_NETSUITE_SUBSIDIARY_SELECTOR: { - route: 'settings/workspaces/:policyID/accounting/net-suite/subsidiary-selector', - getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/net-suite/subsidiary-selector` as const, + 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/', @@ -997,6 +1076,39 @@ 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_NETSUITE_REIMBURSEMENT_ACCOUNT_SELECT: { + route: 'settings/workspaces/:policyID/connections/netsuite/advanced/reimbursement-account/select', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/connections/netsuite/advanced/reimbursement-account/select` as const, + }, + POLICY_ACCOUNTING_NETSUITE_COLLECTION_ACCOUNT_SELECT: { + route: 'settings/workspaces/:policyID/connections/netsuite/advanced/collection-account/select', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/connections/netsuite/advanced/collection-account/select` as const, + }, + POLICY_ACCOUNTING_NETSUITE_EXPENSE_REPORT_APPROVAL_LEVEL_SELECT: { + route: 'settings/workspaces/:policyID/connections/netsuite/advanced/expense-report-approval-level/select', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/connections/netsuite/advanced/expense-report-approval-level/select` as const, + }, + POLICY_ACCOUNTING_NETSUITE_VENDOR_BILL_APPROVAL_LEVEL_SELECT: { + route: 'settings/workspaces/:policyID/connections/netsuite/advanced/vendor-bill-approval-level/select', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/connections/netsuite/advanced/vendor-bill-approval-level/select` as const, + }, + POLICY_ACCOUNTING_NETSUITE_JOURNAL_ENTRY_APPROVAL_LEVEL_SELECT: { + route: 'settings/workspaces/:policyID/connections/netsuite/advanced/journal-entry-approval-level/select', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/connections/netsuite/advanced/journal-entry-approval-level/select` as const, + }, + POLICY_ACCOUNTING_NETSUITE_APPROVAL_ACCOUNT_SELECT: { + route: 'settings/workspaces/:policyID/connections/netsuite/advanced/approval-account/select', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/connections/netsuite/advanced/approval-account/select` as const, + }, + POLICY_ACCOUNTING_NETSUITE_CUSTOM_FORM_ID: { + route: 'settings/workspaces/:policyID/connections/netsuite/advanced/custom-form-id/:expenseType', + getRoute: (policyID: string, expenseType: ValueOf) => + `settings/workspaces/${policyID}/connections/netsuite/advanced/custom-form-id/${expenseType}` 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, @@ -1009,6 +1121,42 @@ const ROUTES = { route: 'settings/workspaces/:policyID/accounting/sage-intacct/existing-connections', getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/sage-intacct/existing-connections` as const, }, + POLICY_ACCOUNTING_SAGE_INTACCT_EXPORT: { + route: 'settings/workspaces/:policyID/accounting/sage-intacct/export', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/sage-intacct/export` as const, + }, + POLICY_ACCOUNTING_SAGE_INTACCT_PREFERRED_EXPORTER: { + route: 'settings/workspaces/:policyID/accounting/sage-intacct/export/preferred-exporter', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/sage-intacct/export/preferred-exporter` as const, + }, + POLICY_ACCOUNTING_SAGE_INTACCT_EXPORT_DATE: { + route: 'settings/workspaces/:policyID/accounting/sage-intacct/export/date', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/sage-intacct/export/date` as const, + }, + POLICY_ACCOUNTING_SAGE_INTACCT_REIMBURSABLE_EXPENSES: { + route: 'settings/workspaces/:policyID/accounting/sage-intacct/export/reimbursable', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/sage-intacct/export/reimbursable` as const, + }, + POLICY_ACCOUNTING_SAGE_INTACCT_NON_REIMBURSABLE_EXPENSES: { + route: 'settings/workspaces/:policyID/accounting/sage-intacct/export/nonreimbursable', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/sage-intacct/export/nonreimbursable` as const, + }, + POLICY_ACCOUNTING_SAGE_INTACCT_DEFAULT_VENDOR: { + route: 'settings/workspaces/:policyID/accounting/sage-intacct/export/:reimbursable/default-vendor', + getRoute: (policyID: string, reimbursable: string) => `settings/workspaces/${policyID}/accounting/sage-intacct/export/${reimbursable}/default-vendor` as const, + }, + POLICY_ACCOUNTING_SAGE_INTACCT_NON_REIMBURSABLE_CREDIT_CARD_ACCOUNT: { + route: 'settings/workspaces/:policyID/accounting/sage-intacct/export/nonreimbursable/credit-card-account', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/sage-intacct/export/nonreimbursable/credit-card-account` as const, + }, + POLICY_ACCOUNTING_SAGE_INTACCT_ADVANCED: { + route: 'settings/workspaces/:policyID/accounting/sage-intacct/advanced', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/sage-intacct/advanced` as const, + }, + POLICY_ACCOUNTING_SAGE_INTACCT_PAYMENT_ACCOUNT: { + route: 'settings/workspaces/:policyID/accounting/sage-intacct/advanced/payment-account', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/sage-intacct/advanced/payment-account` as const, + }, } as const; /** diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 83a110609afd..062f40ad251c 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -186,6 +186,13 @@ const SCREENS = { TRANSACTION_DUPLICATE: { REVIEW: 'Transaction_Duplicate_Review', + MERCHANT: 'Transaction_Duplicate_Merchant', + CATEGORY: 'Transaction_Duplicate_Category', + TAG: 'Transaction_Duplicate_Tag', + DESCRIPTION: 'Transaction_Duplicate_Description', + TAX_CODE: 'Transaction_Duplicate_Tax_Code', + REIMBURSABLE: 'Transaction_Duplicate_Reimburable', + BILLABLE: 'Transaction_Duplicate_Billable', }, IOU_SEND: { @@ -272,7 +279,12 @@ 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_SUBSIDIARY_SELECTOR: 'Policy_Accounting_Net_Suite_Subsidiary_Selector', + 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', NETSUITE_PREFERRED_EXPORTER_SELECT: 'Policy_Accounting_NetSuite_Preferred_Exporter_Select', NETSUITE_DATE_SELECT: 'Policy_Accounting_NetSuite_Date_Select', @@ -286,9 +298,26 @@ 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', + NETSUITE_REIMBURSEMENT_ACCOUNT_SELECT: 'Policy_Accounting_NetSuite_Reimbursement_Account_Select', + NETSUITE_COLLECTION_ACCOUNT_SELECT: 'Policy_Accounting_NetSuite_Collection_Account_Select', + NETSUITE_EXPENSE_REPORT_APPROVAL_LEVEL_SELECT: 'Policy_Accounting_NetSuite_Expense_Report_Approval_Level_Select', + NETSUITE_VENDOR_BILL_APPROVAL_LEVEL_SELECT: 'Policy_Accounting_NetSuite_Vendor_Bill_Approval_Level_Select', + NETSUITE_JOURNAL_ENTRY_APPROVAL_LEVEL_SELECT: 'Policy_Accounting_NetSuite_Journal_Entry_Approval_Level_Select', + NETSUITE_APPROVAL_ACCOUNT_SELECT: 'Policy_Accounting_NetSuite_Approval_Account_Select', + NETSUITE_CUSTOM_FORM_ID: 'Policy_Accounting_NetSuite_Custom_Form_ID', 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', + SAGE_INTACCT_EXPORT: 'Policy_Accounting_Sage_Intacct_Export', + SAGE_INTACCT_PREFERRED_EXPORTER: 'Policy_Accounting_Sage_Intacct_Preferred_Exporter', + SAGE_INTACCT_EXPORT_DATE: 'Policy_Accounting_Sage_Intacct_Export_Date', + SAGE_INTACCT_REIMBURSABLE_EXPENSES: 'Policy_Accounting_Sage_Intacct_Reimbursable_Expenses', + SAGE_INTACCT_NON_REIMBURSABLE_EXPENSES: 'Policy_Accounting_Sage_Intacct_Non_Reimbursable_Expenses', + SAGE_INTACCT_DEFAULT_VENDOR: 'Policy_Accounting_Sage_Intacct_Default_Vendor', + SAGE_INTACCT_NON_REIMBURSABLE_CREDIT_CARD_ACCOUNT: 'Policy_Accounting_Sage_Intacct_Non_Reimbursable_Credit_Card_Account', + SAGE_INTACCT_ADVANCED: 'Policy_Accounting_Sage_Intacct_Advanced', + SAGE_INTACCT_PAYMENT_ACCOUNT: 'Policy_Accounting_Sage_Intacct_Payment_Account', }, INITIAL: 'Workspace_Initial', PROFILE: 'Workspace_Profile', @@ -312,6 +341,13 @@ const SCREENS = { TAG_EDIT: 'Tag_Edit', TAXES: 'Workspace_Taxes', REPORT_FIELDS: 'Workspace_ReportFields', + REPORT_FIELD_SETTINGS: 'Workspace_ReportField_Settings', + REPORT_FIELDS_CREATE: 'Workspace_ReportFields_Create', + REPORT_FIELDS_LIST_VALUES: 'Workspace_ReportFields_ListValues', + REPORT_FIELDS_ADD_VALUE: 'Workspace_ReportFields_AddValue', + REPORT_FIELDS_VALUE_SETTINGS: 'Workspace_ReportFields_ValueSettings', + REPORT_FIELDS_EDIT_VALUE: 'Workspace_ReportFields_EditValue', + REPORT_FIELDS_EDIT_INITIAL_VALUE: 'Workspace_ReportFields_EditInitialValue', TAX_EDIT: 'Workspace_Tax_Edit', TAX_NAME: 'Workspace_Tax_Name', TAX_VALUE: 'Workspace_Tax_Value', @@ -349,6 +385,7 @@ const SCREENS = { DISTANCE_RATE_EDIT: 'Distance_Rate_Edit', DISTANCE_RATE_TAX_RECLAIMABLE_ON_EDIT: 'Distance_Rate_Tax_Reclaimable_On_Edit', DISTANCE_RATE_TAX_RATE_EDIT: 'Distance_Rate_Tax_Rate_Edit', + UPGRADE: 'Workspace_Upgrade', }, EDIT_REQUEST: { diff --git a/src/components/AddPlaidBankAccount.tsx b/src/components/AddPlaidBankAccount.tsx index a112b36705c3..4de286183ea8 100644 --- a/src/components/AddPlaidBankAccount.tsx +++ b/src/components/AddPlaidBankAccount.tsx @@ -153,7 +153,7 @@ function AddPlaidBankAccount({ return unsubscribeToNavigationShortcuts; // disabling this rule, as we want this to run only on the first render - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps }, []); useEffect(() => { 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/AddressSearch/index.tsx b/src/components/AddressSearch/index.tsx index 9bd6142b5604..2679a550f72f 100644 --- a/src/components/AddressSearch/index.tsx +++ b/src/components/AddressSearch/index.tsx @@ -24,7 +24,24 @@ import CONST from '@src/CONST'; import type {Address} from '@src/types/onyx/PrivatePersonalDetails'; import CurrentLocationButton from './CurrentLocationButton'; import isCurrentTargetInsideContainer from './isCurrentTargetInsideContainer'; -import type {AddressSearchProps} from './types'; +import type {AddressSearchProps, PredefinedPlace} from './types'; + +/** + * Check if the place matches the search by the place name or description. + * @param search The search string for a place + * @param place The place to check for a match on the search + * @returns true if search is related to place, otherwise it returns false. + */ +function isPlaceMatchForSearch(search: string, place: PredefinedPlace): boolean { + if (!search) { + return true; + } + if (!place) { + return false; + } + const fullSearchSentence = `${place.name ?? ''} ${place.description}`; + return search.split(' ').every((searchTerm) => !searchTerm || fullSearchSentence.toLocaleLowerCase().includes(searchTerm.toLocaleLowerCase())); +} // The error that's being thrown below will be ignored until we fork the // react-native-google-places-autocomplete repo and replace the @@ -42,6 +59,7 @@ function AddressSearch( isLimitedToUSA = false, label, maxInputLength, + onFocus, onBlur, onInputChange, onPress, @@ -72,7 +90,7 @@ function AddressSearch( const [isTyping, setIsTyping] = useState(false); const [isFocused, setIsFocused] = useState(false); // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - const [searchValue, setSearchValue] = useState(value || defaultValue || ''); + const [searchValue, setSearchValue] = useState(''); const [locationErrorCode, setLocationErrorCode] = useState(null); const [isFetchingCurrentLocation, setIsFetchingCurrentLocation] = useState(false); const shouldTriggerGeolocationCallbacks = useRef(true); @@ -282,7 +300,7 @@ function AddressSearch( // eslint-disable-next-line react/jsx-no-useless-fragment <> {(predefinedPlaces?.length ?? 0) > 0 && ( - <> + {/* This will show current location button in list if there are some recent destinations */} {shouldShowCurrentLocationButton && ( )} {!value && {translate('common.recentDestinations')}} - + )} ); @@ -304,10 +322,16 @@ function AddressSearch( }; }, []); + const filteredPredefinedPlaces = useMemo(() => { + if (!searchValue) { + return predefinedPlaces ?? []; + } + return predefinedPlaces?.filter((predefinedPlace) => isPlaceMatchForSearch(searchValue, predefinedPlace)) ?? []; + }, [predefinedPlaces, searchValue]); + const listEmptyComponent = useCallback( - () => - !!isOffline || !isTyping ? null : {translate('common.noResultsFound')}, - [isOffline, isTyping, styles, translate], + () => (!isTyping ? null : {translate('common.noResultsFound')}), + [isTyping, styles, translate], ); const listLoader = useCallback( @@ -348,7 +372,7 @@ function AddressSearch( fetchDetails suppressDefaultStyles enablePoweredByContainer={false} - predefinedPlaces={predefinedPlaces ?? undefined} + predefinedPlaces={filteredPredefinedPlaces} listEmptyComponent={listEmptyComponent} listLoaderComponent={listLoader} renderHeaderComponent={renderHeaderComponent} @@ -357,7 +381,7 @@ function AddressSearch( const subtitle = data.isPredefinedPlace ? data.description : data.structured_formatting.secondary_text; return ( - {!!title && {title}} + {!!title && {title}} {subtitle} ); @@ -391,6 +415,7 @@ function AddressSearch( shouldSaveDraft, onFocus: () => { setIsFocused(true); + onFocus?.(); }, onBlur: (event) => { if (!isCurrentTargetInsideContainer(event, containerRef)) { @@ -420,10 +445,11 @@ function AddressSearch( }} styles={{ textInputContainer: [styles.flexColumn], - listView: [StyleUtils.getGoogleListViewStyle(displayListViewBorder), styles.overflowAuto, styles.borderLeft, styles.borderRight, !isFocused && {height: 0}], + listView: [StyleUtils.getGoogleListViewStyle(displayListViewBorder), styles.borderLeft, styles.borderRight, !isFocused && styles.h0], row: [styles.pv4, styles.ph3, styles.overflowAuto], description: [styles.googleSearchText], - separator: [styles.googleSearchSeparator], + separator: [styles.googleSearchSeparator, styles.overflowAuto], + container: [styles.mh100], }} numberOfLines={2} isRowScrollable={false} @@ -447,11 +473,13 @@ function AddressSearch( ) } placeholder="" - /> - setLocationErrorCode(null)} - locationErrorCode={locationErrorCode} - /> + listViewDisplayed + > + setLocationErrorCode(null)} + locationErrorCode={locationErrorCode} + /> + {isFetchingCurrentLocation && } diff --git a/src/components/AddressSearch/types.ts b/src/components/AddressSearch/types.ts index 82e4c3c3fc37..b654fcad99da 100644 --- a/src/components/AddressSearch/types.ts +++ b/src/components/AddressSearch/types.ts @@ -23,6 +23,10 @@ type StreetValue = { street: string; }; +type PredefinedPlace = Place & { + name?: string; +}; + type AddressSearchProps = { /** The ID used to uniquely identify the input in a Form */ inputID?: string; @@ -30,6 +34,9 @@ type AddressSearchProps = { /** Saves a draft of the input value when used in a form */ shouldSaveDraft?: boolean; + /** Callback that is called when the text input is focused */ + onFocus?: () => void; + /** Callback that is called when the text input is blurred */ onBlur?: () => void; @@ -64,7 +71,7 @@ type AddressSearchProps = { canUseCurrentLocation?: boolean; /** A list of predefined places that can be shown when the user isn't searching for something */ - predefinedPlaces?: Place[] | null; + predefinedPlaces?: PredefinedPlace[] | null; /** A map of inputID key names */ renamedInputKeys?: Address; @@ -84,4 +91,4 @@ type AddressSearchProps = { type IsCurrentTargetInsideContainerType = (event: FocusEvent | NativeSyntheticEvent, containerRef: RefObject) => boolean; -export type {CurrentLocationButtonProps, AddressSearchProps, IsCurrentTargetInsideContainerType, StreetValue}; +export type {CurrentLocationButtonProps, AddressSearchProps, IsCurrentTargetInsideContainerType, StreetValue, PredefinedPlace}; diff --git a/src/components/AmountForm.tsx b/src/components/AmountForm.tsx index 3319a28c58b9..1eb272dce49a 100644 --- a/src/components/AmountForm.tsx +++ b/src/components/AmountForm.tsx @@ -135,7 +135,7 @@ function AmountForm( setNewAmount(MoneyRequestUtils.stripDecimalsFromAmount(currentAmount)); // we want to update only when decimals change (setNewAmount also changes when decimals change). - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps }, [decimals]); /** diff --git a/src/components/AnimatedStep/index.tsx b/src/components/AnimatedStep/index.tsx index 2fb3e3167ff8..6de7d0c2b013 100644 --- a/src/components/AnimatedStep/index.tsx +++ b/src/components/AnimatedStep/index.tsx @@ -37,6 +37,7 @@ function AnimatedStep({onAnimationEnd, direction = CONST.ANIMATION_DIRECTION.IN, }} duration={CONST.ANIMATED_TRANSITION} animation={animationStyle} + // eslint-disable-next-line react-compiler/react-compiler useNativeDriver={useNativeDriver} style={style} > diff --git a/src/components/AttachmentModal.tsx b/src/components/AttachmentModal.tsx index df027ed6edb4..368347847890 100644 --- a/src/components/AttachmentModal.tsx +++ b/src/components/AttachmentModal.tsx @@ -265,7 +265,7 @@ function AttachmentModal({ } setIsModalOpen(false); - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps }, [isModalOpen, isConfirmButtonDisabled, onConfirm, file, sourceState]); /** @@ -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; @@ -367,7 +367,7 @@ function AttachmentModal({ onModalClose(); } - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps }, [onModalClose]); /** @@ -428,7 +428,7 @@ function AttachmentModal({ }); } return menuItems; - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps }, [isReceiptAttachment, transaction, file, sourceState, iouType]); // There are a few things that shouldn't be set until we absolutely know if the file is a receipt or an attachment. diff --git a/src/components/AttachmentPicker/index.native.tsx b/src/components/AttachmentPicker/index.native.tsx index 154fcf838c86..7d4fbd97f4f7 100644 --- a/src/components/AttachmentPicker/index.native.tsx +++ b/src/components/AttachmentPicker/index.native.tsx @@ -222,6 +222,7 @@ function AttachmentPicker({type = CONST.ATTACHMENT_PICKER_TYPE.FILE, children, s * @param onCanceledHandler A callback that will be called without a selected attachment */ const open = (onPickedHandler: (file: FileObject) => void, onCanceledHandler: () => void = () => {}) => { + // eslint-disable-next-line react-compiler/react-compiler completeAttachmentSelection.current = onPickedHandler; onCanceled.current = onCanceledHandler; setIsVisible(true); diff --git a/src/components/AttachmentPicker/index.tsx b/src/components/AttachmentPicker/index.tsx index a6ff9cb8d27a..669b26724a02 100644 --- a/src/components/AttachmentPicker/index.tsx +++ b/src/components/AttachmentPicker/index.tsx @@ -46,6 +46,7 @@ function AttachmentPicker({children, type = CONST.ATTACHMENT_PICKER_TYPE.FILE}: // Cleanup after selecting a file to start from a fresh state if (fileInput.current) { + // eslint-disable-next-line react-compiler/react-compiler fileInput.current.value = ''; } }} 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..c32919ecff6e 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); @@ -68,6 +66,7 @@ function AttachmentCarouselPager( const pageScrollHandler = usePageScrollHandler((e) => { 'worklet'; + // eslint-disable-next-line react-compiler/react-compiler activePage.value = e.position; isPagerScrolling.value = e.offset !== 0; }, []); @@ -80,42 +79,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..e0f7571af8c7 100644 --- a/src/components/Attachments/AttachmentCarousel/index.native.tsx +++ b/src/components/Attachments/AttachmentCarousel/index.native.tsx @@ -57,7 +57,7 @@ function AttachmentCarousel({report, reportActions, parentReportActions, source, onNavigate(targetAttachments[initialPage]); } } - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps }, [reportActions, compareImage]); /** Updates the page state when the user navigates between attachments */ @@ -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..f7ef2c6529ce 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]); @@ -101,7 +115,7 @@ function AttachmentCarousel({report, reportActions, parentReportActions, source, scrollRef.current.scrollToIndex({index: page, animated: false}); // The hook is not supposed to run on page change, so we keep the page out of the dependencies - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps }, [cellWidth]); /** Updates the page state when the user navigates between attachments */ @@ -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..ed195fd943f1 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); @@ -42,7 +45,7 @@ function useCarouselArrows() { useEffect(() => { autoHideArrows(); - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps }, []); return {shouldShowArrows, setShouldShowArrows, autoHideArrows, cancelAutoHideArrows}; diff --git a/src/components/Attachments/AttachmentCarousel/useCarouselContextEvents.ts b/src/components/Attachments/AttachmentCarousel/useCarouselContextEvents.ts new file mode 100644 index 000000000000..cc2c3c5c8229 --- /dev/null +++ b/src/components/Attachments/AttachmentCarousel/useCarouselContextEvents.ts @@ -0,0 +1,64 @@ +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; + } + + // eslint-disable-next-line react-compiler/react-compiler + 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/Attachments/AttachmentView/AttachmentViewPdf/BaseAttachmentViewPdf.tsx b/src/components/Attachments/AttachmentView/AttachmentViewPdf/BaseAttachmentViewPdf.tsx index 765bc2b2a4f2..742b1213fa31 100644 --- a/src/components/Attachments/AttachmentView/AttachmentViewPdf/BaseAttachmentViewPdf.tsx +++ b/src/components/Attachments/AttachmentView/AttachmentViewPdf/BaseAttachmentViewPdf.tsx @@ -25,7 +25,7 @@ function BaseAttachmentViewPdf({ return; } attachmentCarouselPagerContext.onScaleChanged(1); - // eslint-disable-next-line react-hooks/exhaustive-deps -- we just want to call this function when component is mounted + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps -- we just want to call this function when component is mounted }, []); /** diff --git a/src/components/Attachments/AttachmentView/AttachmentViewPdf/index.android.tsx b/src/components/Attachments/AttachmentView/AttachmentViewPdf/index.android.tsx index 9f74b5c7bfe8..8c4af3275bd8 100644 --- a/src/components/Attachments/AttachmentView/AttachmentViewPdf/index.android.tsx +++ b/src/components/Attachments/AttachmentView/AttachmentViewPdf/index.android.tsx @@ -41,6 +41,7 @@ function AttachmentViewPdf(props: AttachmentViewPdfProps) { // enable the pager scroll so that the user // can swipe to the next attachment otherwise disable it. if (translateX > translateY && translateX > SCROLL_THRESHOLD && scale.value === 1 && allowEnablingScroll) { + // eslint-disable-next-line react-compiler/react-compiler isScrollEnabled.value = true; } else if (translateY > SCROLL_THRESHOLD) { isScrollEnabled.value = false; diff --git a/src/components/AutoCompleteSuggestions/BaseAutoCompleteSuggestions.tsx b/src/components/AutoCompleteSuggestions/BaseAutoCompleteSuggestions.tsx index 70d70a8c1844..2d22a2560bb0 100644 --- a/src/components/AutoCompleteSuggestions/BaseAutoCompleteSuggestions.tsx +++ b/src/components/AutoCompleteSuggestions/BaseAutoCompleteSuggestions.tsx @@ -56,6 +56,7 @@ function BaseAutoCompleteSuggestions({ useEffect(() => { if (measuredHeightOfSuggestionRows === prevRowHeightRef.current) { + // eslint-disable-next-line react-compiler/react-compiler fadeInOpacity.value = withTiming(1, { duration: 70, easing: Easing.inOut(Easing.ease), diff --git a/src/components/AvatarCropModal/AvatarCropModal.tsx b/src/components/AvatarCropModal/AvatarCropModal.tsx index 66814a44cf95..1a606b35f6d2 100644 --- a/src/components/AvatarCropModal/AvatarCropModal.tsx +++ b/src/components/AvatarCropModal/AvatarCropModal.tsx @@ -123,6 +123,7 @@ function AvatarCropModal({imageUri = '', imageName = '', imageType = '', onClose ImageSize.getSize(imageUri).then(({width, height, rotation: orginalRotation}) => { // On Android devices ImageSize library returns also rotation parameter. if (orginalRotation === 90 || orginalRotation === 270) { + // eslint-disable-next-line react-compiler/react-compiler originalImageHeight.value = width; originalImageWidth.value = height; } else { diff --git a/src/components/AvatarCropModal/Slider.tsx b/src/components/AvatarCropModal/Slider.tsx index 9a9da65befa0..67aa89c9c550 100644 --- a/src/components/AvatarCropModal/Slider.tsx +++ b/src/components/AvatarCropModal/Slider.tsx @@ -7,6 +7,7 @@ import type {SharedValue} from 'react-native-reanimated'; import Tooltip from '@components/Tooltip'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; +import * as Browser from '@libs/Browser'; import ControlSelection from '@libs/ControlSelection'; type SliderProps = { @@ -62,7 +63,7 @@ function Slider({sliderValue, gestureCallbacks}: SliderProps) { shiftVertical={-2} > {/* pointerEventsNone is a workaround to make sure the pan gesture works correctly on mobile safari */} - + )} diff --git a/src/components/Button/index.tsx b/src/components/Button/index.tsx index 88ae8d48a871..4b3f0f70db24 100644 --- a/src/components/Button/index.tsx +++ b/src/components/Button/index.tsx @@ -147,7 +147,7 @@ function KeyboardShortcutComponent({isDisabled = false, isLoading = false, onPre priority: enterKeyEventListenerPriority, shouldPreventDefault: false, }), - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps [shouldDisableEnterShortcut, isFocused], ); diff --git a/src/components/ButtonWithDropdownMenu/types.ts b/src/components/ButtonWithDropdownMenu/types.ts index 1ad2ccb0d717..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; @@ -23,6 +23,10 @@ type DropdownOption = { iconDescription?: string; onSelected?: () => void; disabled?: boolean; + iconFill?: string; + interactive?: boolean; + numberOfLinesTitle?: number; + titleStyle?: ViewStyle; }; type ButtonWithDropdownMenuProps = { diff --git a/src/components/Composer/index.native.tsx b/src/components/Composer/index.native.tsx index 9e66c0b20c99..c5f2e07eef80 100644 --- a/src/components/Composer/index.native.tsx +++ b/src/components/Composer/index.native.tsx @@ -50,6 +50,7 @@ function Composer( * @param {Element} el */ const setTextInputRef = useCallback((el: AnimatedMarkdownTextInputRef) => { + // eslint-disable-next-line react-compiler/react-compiler textInput.current = el; if (typeof ref !== 'function' || textInput.current === null) { return; @@ -60,7 +61,7 @@ function Composer( // this.textInput = el} /> this will not // return a ref to the component, but rather the HTML element by default ref(textInput.current); - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps }, []); useEffect(() => { @@ -93,6 +94,7 @@ function Composer( readOnly={isDisabled} onBlur={(e) => { if (!isFocused) { + // eslint-disable-next-line react-compiler/react-compiler shouldResetFocus.current = true; // detect the input is blurred when the page is hidden } props?.onBlur?.(e); diff --git a/src/components/Composer/index.tsx b/src/components/Composer/index.tsx index f4a5174c2602..a41f983434d8 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; }); @@ -277,8 +277,9 @@ function Composer( if (!textInput.current || prevScroll === undefined) { return; } + // eslint-disable-next-line react-compiler/react-compiler textInput.current.scrollTop = prevScroll; - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps }, [isComposerFullSize]); useHtmlPaste(textInput, handlePaste, true); @@ -295,7 +296,7 @@ function Composer( } ReportActionComposeFocusManager.clear(); }; - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps }, []); const handleKeyPress = useCallback( diff --git a/src/components/ConfirmationPage.tsx b/src/components/ConfirmationPage.tsx index d1a73b7933fe..883e7261f386 100644 --- a/src/components/ConfirmationPage.tsx +++ b/src/components/ConfirmationPage.tsx @@ -17,7 +17,7 @@ type ConfirmationPageProps = { heading: string; /** Description of the confirmation page */ - description: string; + description: React.ReactNode; /** The text for the button label */ buttonText?: string; diff --git a/src/components/ConnectToNetSuiteButton/index.tsx b/src/components/ConnectToNetSuiteButton/index.tsx index fc948503a127..a0cd36671117 100644 --- a/src/components/ConnectToNetSuiteButton/index.tsx +++ b/src/components/ConnectToNetSuiteButton/index.tsx @@ -26,8 +26,7 @@ function ConnectToNetSuiteButton({policyID, shouldDisconnectIntegrationBeforeCon return; } - // TODO: Will be updated to new token input page - Navigation.navigate(ROUTES.POLICY_ACCOUNTING_NETSUITE_SUBSIDIARY_SELECTOR.getRoute(policyID)); + Navigation.navigate(ROUTES.POLICY_ACCOUNTING_NETSUITE_TOKEN_INPUT.getRoute(policyID)); }} text={translate('workspace.accounting.setup')} style={styles.justifyContentCenter} @@ -39,8 +38,7 @@ function ConnectToNetSuiteButton({policyID, shouldDisconnectIntegrationBeforeCon onConfirm={() => { removePolicyConnection(policyID, integrationToDisconnect); - // TODO: Will be updated to new token input page - Navigation.navigate(ROUTES.POLICY_ACCOUNTING_NETSUITE_SUBSIDIARY_SELECTOR.getRoute(policyID)); + Navigation.navigate(ROUTES.POLICY_ACCOUNTING_NETSUITE_TOKEN_INPUT.getRoute(policyID)); setIsDisconnectModalOpen(false); }} integrationToConnect={CONST.POLICY.CONNECTIONS.NAME.NETSUITE} diff --git a/src/components/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..4bcfdc61077f 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,15 @@ 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; + + /** Whether or not to block user from accessing the page */ + shouldBeBlocked?: boolean; }; type ConnectionLayoutContentProps = Pick; @@ -81,7 +86,6 @@ function ConnectionLayoutContent({title, titleStyle, children, titleAlreadyTrans function ConnectionLayout({ displayName, - onBackButtonPressRoute, headerTitle, children, title, @@ -96,6 +100,9 @@ function ConnectionLayout({ shouldUseScrollView = true, headerTitleAlreadyTranslated, titleAlreadyTranslated, + reverseConnectionEmptyCheck = false, + onBackButtonPress = () => Navigation.goBack(), + shouldBeBlocked = false, }: ConnectionLayoutProps) { const {translate} = useLocalize(); @@ -120,7 +127,7 @@ function ConnectionLayout({ policyID={policyID} accessVariants={accessVariants} featureName={featureName} - shouldBeBlocked={isConnectionEmpty} + shouldBeBlocked={(reverseConnectionEmptyCheck ? !isConnectionEmpty : isConnectionEmpty) || shouldBeBlocked} > Navigation.goBack(onBackButtonPressRoute)} + onBackButtonPress={onBackButtonPress} /> {shouldUseScrollView ? ( {renderSelectionContent} diff --git a/src/components/CountrySelector.tsx b/src/components/CountrySelector.tsx index 62fdc85687e1..9ff04874c6da 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); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [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-compiler/react-compiler, react-hooks/exhaustive-deps + }, [countryFromUrl, isFocused, onBlur]); return ( { // This will cause the form to revalidate and remove any error related to currency onInputChange(currency); - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps }, [currency]); return ( diff --git a/src/components/DisplayNames/DisplayNamesTooltipItem.tsx b/src/components/DisplayNames/DisplayNamesTooltipItem.tsx index 430c00cf8804..b206d4bcf51d 100644 --- a/src/components/DisplayNames/DisplayNamesTooltipItem.tsx +++ b/src/components/DisplayNames/DisplayNamesTooltipItem.tsx @@ -64,7 +64,7 @@ function DisplayNamesTooltipItem({ if (!childRefs.current?.[index] || !el) { return; } - // eslint-disable-next-line no-param-reassign + // eslint-disable-next-line react-compiler/react-compiler, no-param-reassign childRefs.current[index] = el; }} style={[textStyles, styles.pre]} diff --git a/src/components/EReceiptThumbnail.tsx b/src/components/EReceiptThumbnail.tsx index f4216dcc9f8a..a8d636db460b 100644 --- a/src/components/EReceiptThumbnail.tsx +++ b/src/components/EReceiptThumbnail.tsx @@ -5,6 +5,7 @@ import {withOnyx} from 'react-native-onyx'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import * as ReportUtils from '@libs/ReportUtils'; +import * as TripReservationUtils from '@libs/TripReservationUtils'; import colors from '@styles/theme/colors'; import variables from '@styles/variables'; import CONST from '@src/CONST'; @@ -56,7 +57,8 @@ const backgroundImages = { function EReceiptThumbnail({transaction, borderRadius, fileExtension, isReceiptThumbnail = false, centerIconV = true, iconSize = 'large'}: EReceiptThumbnailProps) { const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); - const colorCode = isReceiptThumbnail ? StyleUtils.getFileExtensionColorCode(fileExtension) : StyleUtils.getEReceiptColorCode(transaction); + const {tripIcon, tripBGColor} = TripReservationUtils.getTripEReceiptData(transaction); + const colorCode = tripBGColor ?? (isReceiptThumbnail ? StyleUtils.getFileExtensionColorCode(fileExtension) : StyleUtils.getEReceiptColorCode(transaction)); const backgroundImage = useMemo(() => backgroundImages[colorCode], [colorCode]); @@ -141,6 +143,14 @@ function EReceiptThumbnail({transaction, borderRadius, fileExtension, isReceiptT fill={primaryColor} /> ) : null} + {tripIcon ? ( + + ) : null} diff --git a/src/components/EmojiPicker/EmojiPicker.tsx b/src/components/EmojiPicker/EmojiPicker.tsx index 2be8ea4aea7a..edf78283caf9 100644 --- a/src/components/EmojiPicker/EmojiPicker.tsx +++ b/src/components/EmojiPicker/EmojiPicker.tsx @@ -115,6 +115,7 @@ function EmojiPicker({viewportOffsetTop}: EmojiPickerProps, ref: ForwardedRef { const emojiListRef = useAnimatedRef>(); const frequentlyUsedEmojis = useFrequentlyUsedEmojis(); - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps const allEmojis = useMemo(() => EmojiUtils.mergeEmojisWithFrequentlyUsedEmojis(emojis), [frequentlyUsedEmojis]); const headerEmojis = useMemo(() => EmojiUtils.getHeaderEmojis(allEmojis), [allEmojis]); const headerRowIndices = useMemo(() => headerEmojis.map((headerEmoji) => headerEmoji.index), [headerEmojis]); diff --git a/src/components/EmojiPicker/EmojiSkinToneList.tsx b/src/components/EmojiPicker/EmojiSkinToneList.tsx index fb798f1c02c4..3a1832ac40a7 100644 --- a/src/components/EmojiPicker/EmojiSkinToneList.tsx +++ b/src/components/EmojiPicker/EmojiSkinToneList.tsx @@ -38,7 +38,7 @@ function EmojiSkinToneList() { return; } toggleIsSkinToneListVisible(); - // eslint-disable-next-line react-hooks/exhaustive-deps -- only run when preferredSkinTone updates + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps -- only run when preferredSkinTone updates }, [preferredSkinTone]); const currentSkinTone = getSkinToneEmojiFromIndex(preferredSkinTone); diff --git a/src/components/FlatList/index.android.tsx b/src/components/FlatList/index.android.tsx index 1246367d29e8..c8ce7ee10d6b 100644 --- a/src/components/FlatList/index.android.tsx +++ b/src/components/FlatList/index.android.tsx @@ -22,7 +22,7 @@ function CustomFlatList(props: FlatListProps, ref: ForwardedRef) } }, [scrollPosition?.offset, ref]); - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps const onMomentumScrollEnd = useCallback((event: NativeSyntheticEvent) => setScrollPosition({offset: event.nativeEvent.contentOffset.y}), []); useFocusEffect( 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/FloatingActionButton.tsx b/src/components/FloatingActionButton.tsx index 93ffa52bc80b..7c2f5579332a 100644 --- a/src/components/FloatingActionButton.tsx +++ b/src/components/FloatingActionButton.tsx @@ -63,6 +63,7 @@ function FloatingActionButton({onPress, isActive, accessibilityLabel, role}: Flo const buttonRef = ref; useEffect(() => { + // eslint-disable-next-line react-compiler/react-compiler sharedValue.value = withTiming(isActive ? 1 : 0, { duration: 340, easing: Easing.inOut(Easing.ease), diff --git a/src/components/FocusTrap/FocusTrapForScreen/index.web.tsx b/src/components/FocusTrap/FocusTrapForScreen/index.web.tsx index 6a1409ab4a93..628a85c6d705 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. @@ -35,6 +39,7 @@ function FocusTrapForScreen({children}: FocusTrapProps) { useFocusEffect( useCallback(() => { + // eslint-disable-next-line react-compiler/react-compiler activeRouteName = route.name; }, [route]), ); @@ -49,13 +54,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..27eab777097f 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[] = [ @@ -8,8 +9,21 @@ const SCREENS_WITH_AUTOFOCUS: string[] = [ SCREENS.PRIVATE_NOTES.EDIT, SCREENS.SETTINGS.PROFILE.STATUS, SCREENS.SETTINGS.PROFILE.PRONOUNS, + SCREENS.REPORT_SETTINGS.ROOT, + SCREENS.REPORT_SETTINGS.NOTIFICATION_PREFERENCES, + SCREENS.REPORT_PARTICIPANTS.ROOT, + SCREENS.ROOM_MEMBERS_ROOT, SCREENS.NEW_TASK.DETAILS, SCREENS.MONEY_REQUEST.CREATE, + SCREENS.WORKSPACE.INVITE, + 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/FormProvider.tsx b/src/components/Form/FormProvider.tsx index 9df94e4c6114..a4b9df5916af 100644 --- a/src/components/Form/FormProvider.tsx +++ b/src/components/Form/FormProvider.tsx @@ -176,7 +176,7 @@ function FormProvider( onValidate(trimmedStringValues, !hasServerError); // Only run when locales change - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps }, [preferredLocale]); /** @param inputID - The inputID of the input being touched */ @@ -239,6 +239,7 @@ function FormProvider( inputRefs.current[inputID] = newRef; } if (inputProps.value !== undefined) { + // eslint-disable-next-line react-compiler/react-compiler inputValues[inputID] = inputProps.value; } else if (inputProps.shouldSaveDraft && draftValues?.[inputID] !== undefined && inputValues[inputID] === undefined) { inputValues[inputID] = draftValues[inputID]; 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/Expensicons.ts b/src/components/Icon/Expensicons.ts index a0d7a5cb8883..487df5594212 100644 --- a/src/components/Icon/Expensicons.ts +++ b/src/components/Icon/Expensicons.ts @@ -42,6 +42,7 @@ import ChatBubbles from '@assets/images/chatbubbles.svg'; import CheckCircle from '@assets/images/check-circle.svg'; import CheckmarkCircle from '@assets/images/checkmark-circle.svg'; import Checkmark from '@assets/images/checkmark.svg'; +import CircularArrowBackwards from '@assets/images/circular-arrow-backwards.svg'; import Close from '@assets/images/close.svg'; import ClosedSign from '@assets/images/closed-sign.svg'; import Coins from '@assets/images/coins.svg'; @@ -201,6 +202,7 @@ export { Wrench, BackArrow, Bank, + CircularArrowBackwards, Bill, Bell, BellSlash, diff --git a/src/components/Icon/Illustrations.ts b/src/components/Icon/Illustrations.ts index e699badc43ec..5212f5b0edb7 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'; @@ -91,6 +92,7 @@ import ThumbsUpStars from '@assets/images/simple-illustrations/simple-illustrati import TrackShoe from '@assets/images/simple-illustrations/simple-illustration__track-shoe.svg'; import TrashCan from '@assets/images/simple-illustrations/simple-illustration__trashcan.svg'; import TreasureChest from '@assets/images/simple-illustrations/simple-illustration__treasurechest.svg'; +import VirtualCard from '@assets/images/simple-illustrations/simple-illustration__virtualcard.svg'; import WalletAlt from '@assets/images/simple-illustrations/simple-illustration__wallet-alt.svg'; import Workflows from '@assets/images/simple-illustrations/simple-illustration__workflows.svg'; import ExpensifyApprovedLogoLight from '@assets/images/subscription-details__approvedlogo--light.svg'; @@ -176,6 +178,7 @@ export { Binoculars, CompanyCard, ReceiptUpload, + ExpensifyCardIllustration, SplitBill, PiggyBank, Accounting, @@ -194,4 +197,5 @@ export { CheckmarkCircle, CreditCardEyes, LockClosedOrange, + VirtualCard, }; diff --git a/src/components/Image/index.tsx b/src/components/Image/index.tsx index f3cbc332c995..5fe1ba306400 100644 --- a/src/components/Image/index.tsx +++ b/src/components/Image/index.tsx @@ -58,7 +58,7 @@ function Image({source: propsSource, isAuthTokenRequired = false, session, onLoa } return propsSource; // The session prop is not required, as it causes the image to reload whenever the session changes. For more information, please refer to issue #26034. - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps }, [propsSource, isAuthTokenRequired]); /** 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/LHNOptionsList/OptionRowLHN.tsx b/src/components/LHNOptionsList/OptionRowLHN.tsx index c7797a37fd12..431a12d00106 100644 --- a/src/components/LHNOptionsList/OptionRowLHN.tsx +++ b/src/components/LHNOptionsList/OptionRowLHN.tsx @@ -3,6 +3,7 @@ import React, {useCallback, useRef, useState} from 'react'; import type {GestureResponderEvent, ViewStyle} from 'react-native'; import {StyleSheet, View} from 'react-native'; import {useOnyx} from 'react-native-onyx'; +import Badge from '@components/Badge'; import DisplayNames from '@components/DisplayNames'; import Hoverable from '@components/Hoverable'; import Icon from '@components/Icon'; @@ -20,11 +21,12 @@ import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import DateUtils from '@libs/DateUtils'; import DomUtils from '@libs/DomUtils'; -import {parseHtmlToText} from '@libs/OnyxAwareParser'; import * as OptionsListUtils from '@libs/OptionsListUtils'; +import Parser from '@libs/Parser'; import Performance from '@libs/Performance'; import ReportActionComposeFocusManager from '@libs/ReportActionComposeFocusManager'; import * as ReportUtils from '@libs/ReportUtils'; +import * as SubscriptionUtils from '@libs/SubscriptionUtils'; import * as ReportActionContextMenu from '@pages/home/report/ContextMenu/ReportActionContextMenu'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -227,6 +229,13 @@ function OptionRowLHN({reportID, isFocused = false, onSelectRow = () => {}, opti ReportUtils.isSystemChat(report) } /> + {ReportUtils.isChatUsedForOnboarding(report) && SubscriptionUtils.isUserOnFreeTrial() && ( + + )} {isStatusVisible && ( {}, opti numberOfLines={1} accessibilityLabel={translate('accessibilityHints.lastChatMessagePreview')} > - {parseHtmlToText(optionItem.alternateText)} + {Parser.htmlToText(optionItem.alternateText)} ) : null} diff --git a/src/components/LHNOptionsList/OptionRowLHNData.tsx b/src/components/LHNOptionsList/OptionRowLHNData.tsx index 8d61058ed5be..8f3d78546dd3 100644 --- a/src/components/LHNOptionsList/OptionRowLHNData.tsx +++ b/src/components/LHNOptionsList/OptionRowLHNData.tsx @@ -57,7 +57,7 @@ function OptionRowLHNData({ return item; // Listen parentReportAction to update title of thread report when parentReportAction changed // Listen to transaction to update title of transaction report when transaction changed - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps }, [ fullReport, lastReportActionTransaction, 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..2fae3cc89597 100644 --- a/src/components/MagicCodeInput.tsx +++ b/src/components/MagicCodeInput.tsx @@ -192,7 +192,7 @@ function MagicCodeInput( // We have not added: // + the editIndex as the dependency because we don't want to run this logic after focusing on an input to edit it after the user has completed the code. // + the onFulfill as the dependency because onFulfill is changed when the preferred locale changed => avoid auto submit form when preferred locale changed. - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps }, [value, shouldSubmitOnComplete]); /** @@ -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) { @@ -353,7 +353,7 @@ function MagicCodeInput( // We have not added: // + the onChangeText and onKeyPress as the dependencies because we only want to run this when lastPressedDigit changes. - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps }, [lastPressedDigit, isDisableKeyboard]); return ( diff --git a/src/components/MapView/MapView.tsx b/src/components/MapView/MapView.tsx index 283f7c396edb..553be816cf3f 100644 --- a/src/components/MapView/MapView.tsx +++ b/src/components/MapView/MapView.tsx @@ -37,6 +37,7 @@ const MapView = forwardRef( const currentPosition = userLocation ?? initialLocation; const [userInteractedWithMap, setUserInteractedWithMap] = useState(false); const shouldInitializeCurrentPosition = useRef(true); + const [isAccessTokenSet, setIsAccessTokenSet] = useState(false); // Determines if map can be panned to user's detected // location without bothering the user. It will return @@ -138,7 +139,12 @@ const MapView = forwardRef( }, [navigation]); useEffect(() => { - setAccessToken(accessToken); + setAccessToken(accessToken).then((token) => { + if (!token) { + return; + } + setIsAccessTokenSet(true); + }); }, [accessToken]); const setMapIdle = (e: MapState) => { @@ -198,7 +204,7 @@ const MapView = forwardRef( const initCenterCoordinate = useMemo(() => (interactive ? centerCoordinate : undefined), [interactive, centerCoordinate]); const initBounds = useMemo(() => (interactive ? undefined : waypointsBounds), [interactive, waypointsBounds]); - return !isOffline && !!accessToken && !!defaultSettings ? ( + return !isOffline && isAccessTokenSet && !!defaultSettings ? ( ( resetBoundaries(); setShouldResetBoundaries(false); - // eslint-disable-next-line react-hooks/exhaustive-deps -- this effect only needs to run when the boundaries reset is forced + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps -- this effect only needs to run when the boundaries reset is forced }, [shouldResetBoundaries]); useEffect(() => { diff --git a/src/components/MenuItem.tsx b/src/components/MenuItem.tsx index 9fd18524158d..473806aac3af 100644 --- a/src/components/MenuItem.tsx +++ b/src/components/MenuItem.tsx @@ -1,4 +1,3 @@ -import {ExpensiMark} from 'expensify-common'; import type {ImageContentFit} from 'expo-image'; import type {ReactElement, ReactNode} from 'react'; import React, {forwardRef, useContext, useMemo} from 'react'; @@ -14,6 +13,7 @@ import ControlSelection from '@libs/ControlSelection'; import convertToLTR from '@libs/convertToLTR'; import * as DeviceCapabilities from '@libs/DeviceCapabilities'; import getButtonState from '@libs/getButtonState'; +import Parser from '@libs/Parser'; import type {AvatarSource} from '@libs/UserUtils'; import variables from '@styles/variables'; import * as Session from '@userActions/Session'; @@ -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, @@ -429,16 +433,14 @@ function MenuItem( if (!title || !shouldParseTitle) { return ''; } - const parser = new ExpensiMark(); - return parser.replace(title, {shouldEscapeText}); + return Parser.replace(title, {shouldEscapeText}); }, [title, shouldParseTitle, shouldEscapeText]); const helperHtml = useMemo(() => { if (!helperText || !shouldParseHelperText) { return ''; } - const parser = new ExpensiMark(); - return parser.replace(helperText, {shouldEscapeText}); + return Parser.replace(helperText, {shouldEscapeText}); }, [helperText, shouldParseHelperText, shouldEscapeText]); const processedTitle = useMemo(() => { @@ -702,6 +704,7 @@ function MenuItem( )} + {!!furtherDetailsComponent && {furtherDetailsComponent}} {titleComponent} diff --git a/src/components/Modal/BaseModal.tsx b/src/components/Modal/BaseModal.tsx index 2eb073bb39be..d88dde545f3b 100644 --- a/src/components/Modal/BaseModal.tsx +++ b/src/components/Modal/BaseModal.tsx @@ -118,7 +118,7 @@ function BaseModal( } hideModal(true); }, - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps [], ); @@ -242,7 +242,9 @@ function BaseModal( deviceWidth={windowWidth} animationIn={animationIn ?? modalStyleAnimationIn} animationOut={animationOut ?? modalStyleAnimationOut} + // eslint-disable-next-line react-compiler/react-compiler useNativeDriver={useNativeDriverProp && useNativeDriver} + // eslint-disable-next-line react-compiler/react-compiler useNativeDriverForBackdrop={useNativeDriverForBackdrop && useNativeDriver} hideModalContentWhileAnimating={hideModalContentWhileAnimating} animationInTiming={animationInTiming} diff --git a/src/components/Modal/ModalContent.tsx b/src/components/Modal/ModalContent.tsx index 49d3b049220f..f71affe760ac 100644 --- a/src/components/Modal/ModalContent.tsx +++ b/src/components/Modal/ModalContent.tsx @@ -14,7 +14,7 @@ type ModalContentProps = { }; function ModalContent({children, onDismiss = () => {}}: ModalContentProps) { - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps React.useEffect(() => () => onDismiss?.(), []); return children; } diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index 76be659b35b4..80ad2890afaa 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -8,7 +8,6 @@ import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; import * as CurrencyUtils from '@libs/CurrencyUtils'; -import * as HeaderUtils from '@libs/HeaderUtils'; import Navigation from '@libs/Navigation/Navigation'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; import * as ReportUtils from '@libs/ReportUtils'; @@ -22,7 +21,6 @@ import ROUTES from '@src/ROUTES'; import type {Route} from '@src/ROUTES'; import type * as OnyxTypes from '@src/types/onyx'; import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage'; -import {isEmptyObject} from '@src/types/utils/EmptyObject'; import type IconAsset from '@src/types/utils/IconAsset'; import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; import Button from './Button'; @@ -86,27 +84,20 @@ function MoneyReportHeader({policy, report: moneyRequestReport, transactionThrea const [shouldShowHoldMenu, setShouldShowHoldMenu] = useState(false); const {translate} = useLocalize(); const {isOffline} = useNetwork(); - const {isSmallScreenWidth, windowWidth} = useWindowDimensions(); + const {isSmallScreenWidth} = useWindowDimensions(); const {reimbursableSpend} = ReportUtils.getMoneyRequestSpendBreakdown(moneyRequestReport); - const isSettled = ReportUtils.isSettled(moneyRequestReport.reportID); - const isApproved = ReportUtils.isReportApproved(moneyRequestReport); const isOnHold = TransactionUtils.isOnHold(transaction); - const isScanning = TransactionUtils.hasReceipt(transaction) && TransactionUtils.isReceiptBeingScanned(transaction); const isDeletedParentAction = !!requestParentReportAction && ReportActionsUtils.isDeletedAction(requestParentReportAction); - const canHoldOrUnholdRequest = !isEmptyObject(transaction) && !isSettled && !isApproved && !isDeletedParentAction; // Only the requestor can delete the request, admins can only edit it. const isActionOwner = typeof requestParentReportAction?.actorAccountID === 'number' && typeof session?.accountID === 'number' && requestParentReportAction.actorAccountID === session?.accountID; const canDeleteRequest = isActionOwner && ReportUtils.canAddOrDeleteTransactions(moneyRequestReport) && !isDeletedParentAction; - const isPolicyAdmin = policy?.role === CONST.POLICY.ROLE.ADMIN; - const isApprover = ReportUtils.isMoneyRequestReport(moneyRequestReport) && moneyRequestReport?.managerID !== null && session?.accountID === moneyRequestReport?.managerID; const [isHoldMenuVisible, setIsHoldMenuVisible] = useState(false); const [paymentType, setPaymentType] = useState(); const [requestType, setRequestType] = useState(); const canAllowSettlement = ReportUtils.hasUpdatedTotal(moneyRequestReport, policy); const policyType = policy?.type; - const isPayer = ReportUtils.isPayer(session, moneyRequestReport); const isDraft = ReportUtils.isOpenExpenseReport(moneyRequestReport); const [isConfirmModalVisible, setIsConfirmModalVisible] = useState(false); @@ -197,22 +188,6 @@ function MoneyReportHeader({policy, report: moneyRequestReport, transactionThrea TransactionActions.markAsCash(iouTransactionID, reportID); }, [requestParentReportAction, transactionThreadReport?.reportID]); - const changeMoneyRequestStatus = () => { - if (!transactionThreadReport) { - return; - } - const iouTransactionID = ReportActionsUtils.isMoneyRequestAction(requestParentReportAction) - ? ReportActionsUtils.getOriginalMessage(requestParentReportAction)?.IOUTransactionID ?? '-1' - : '-1'; - - if (isOnHold) { - IOU.unholdRequest(iouTransactionID, transactionThreadReport.reportID); - } else { - const activeRoute = encodeURIComponent(Navigation.getActiveRouteWithoutParams()); - Navigation.navigate(ROUTES.MONEY_REQUEST_HOLD_REASON.getRoute(policy?.type ?? CONST.POLICY.TYPE.PERSONAL, iouTransactionID, transactionThreadReport.reportID, activeRoute)); - } - }; - const getStatusIcon: (src: IconAsset) => React.ReactNode = (src) => ( changeMoneyRequestStatus(), - }); - } - if (!isOnHold && (isRequestIOU || canModifyStatus) && !isScanning && !isInvoiceReport) { - threeDotsMenuItems.push({ - icon: Expensicons.Stopwatch, - text: translate('iou.hold'), - onSelected: () => changeMoneyRequestStatus(), - }); - } - } - useEffect(() => { if (isLoadingHoldUseExplained) { return; @@ -290,23 +242,6 @@ function MoneyReportHeader({policy, report: moneyRequestReport, transactionThrea IOU.dismissHoldUseExplanation(); }; - if (isPayer && isSettled && ReportUtils.isExpenseReport(moneyRequestReport)) { - threeDotsMenuItems.push({ - icon: Expensicons.Trashcan, - text: translate('iou.cancelPayment'), - onSelected: () => setIsConfirmModalVisible(true), - }); - } - - // If the report supports adding transactions to it, then it also supports deleting transactions from it. - if (canDeleteRequest && !isEmptyObject(transactionThreadReport)) { - threeDotsMenuItems.push({ - icon: Expensicons.Trashcan, - text: translate('reportActionContextMenu.deleteAction', {action: requestParentReportAction}), - onSelected: () => setIsDeleteRequestModalVisible(true), - }); - } - useEffect(() => { if (canDeleteRequest) { return; @@ -327,9 +262,6 @@ function MoneyReportHeader({policy, report: moneyRequestReport, transactionThrea onBackButtonPress={onBackButtonPress} // Shows border if no buttons or banners are showing below the header shouldShowBorderBottom={!isMoreContentShown} - shouldShowThreeDotsButton - threeDotsMenuItems={threeDotsMenuItems} - threeDotsAnchorPosition={styles.threeDotsPopoverOffsetNoCloseButton(windowWidth)} > {shouldShowSettlementButton && !shouldUseNarrowLayout && ( @@ -436,6 +368,7 @@ function MoneyReportHeader({policy, report: moneyRequestReport, transactionThrea paymentType={paymentType} chatReport={chatReport} moneyRequestReport={moneyRequestReport} + transactionCount={transactionIDs.length} /> )} CurrencyUtils.convertToFrontendAmountAsString(amount); +const defaultOnFormatAmount = (amount: number, currency?: string) => CurrencyUtils.convertToFrontendAmountAsString(amount, currency ?? CONST.CURRENCY.USD); function MoneyRequestAmountInput( { @@ -218,7 +218,7 @@ function MoneyRequestAmountInput( } // we want to re-initialize the state only when the amount changes - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps }, [amount, shouldKeepUserInput]); // Modifies the amount to match the decimals for changed currency. @@ -232,7 +232,7 @@ function MoneyRequestAmountInput( setNewAmount(MoneyRequestUtils.stripDecimalsFromAmount(currentAmount)); // we want to update only when decimals change (setNewAmount also changes when decimals change). - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps }, [setNewAmount]); /** @@ -295,6 +295,7 @@ function MoneyRequestAmountInput( // eslint-disable-next-line no-param-reassign forwardedRef.current = ref; } + // eslint-disable-next-line react-compiler/react-compiler textInput.current = ref; }} selectedCurrencyCode={currency} diff --git a/src/components/MoneyRequestConfirmationList.tsx b/src/components/MoneyRequestConfirmationList.tsx index 8bfcbbeb779e..1fbd6a6b2630 100755 --- a/src/components/MoneyRequestConfirmationList.tsx +++ b/src/components/MoneyRequestConfirmationList.tsx @@ -284,7 +284,7 @@ function MoneyRequestConfirmationList({ }, [isEditingSplitBill, hasSmartScanFailed, transaction, didConfirmSplit]); const isMerchantEmpty = useMemo(() => !iouMerchant || TransactionUtils.isMerchantMissing(transaction), [transaction, iouMerchant]); - const isMerchantRequired = (isPolicyExpenseChat || isTypeInvoice) && (!isScanRequest || isEditingSplitBill) && shouldShowMerchant; + const isMerchantRequired = isPolicyExpenseChat && (!isScanRequest || isEditingSplitBill) && shouldShowMerchant; const isCategoryRequired = !!policy?.requiresCategory; @@ -300,7 +300,7 @@ function MoneyRequestConfirmationList({ // reset the form error whenever the screen gains or loses focus setFormError(''); - // eslint-disable-next-line react-hooks/exhaustive-deps -- we don't want this effect to run if it's just setFormError that changes + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps -- we don't want this effect to run if it's just setFormError that changes }, [isFocused, transaction, shouldDisplayFieldError, hasSmartScanFailed, didConfirmSplit]); useEffect(() => { @@ -329,10 +329,10 @@ function MoneyRequestConfirmationList({ taxCode = transaction?.taxCode ?? TransactionUtils.getDefaultTaxCode(policy, transaction) ?? ''; } const taxPercentage = TransactionUtils.getTaxValue(policy, transaction, taxCode) ?? ''; - const taxAmount = TransactionUtils.calculateTaxAmount(taxPercentage, taxableAmount); + const taxAmount = TransactionUtils.calculateTaxAmount(taxPercentage, taxableAmount, currency); const taxAmountInSmallestCurrencyUnits = CurrencyUtils.convertToBackendAmount(Number.parseFloat(taxAmount.toString())); IOU.setMoneyRequestTaxAmount(transaction?.transactionID ?? '', taxAmountInSmallestCurrencyUnits); - }, [policy, shouldShowTax, previousTransactionAmount, previousTransactionCurrency, transaction, isDistanceRequest, customUnitRateID]); + }, [policy, shouldShowTax, previousTransactionAmount, previousTransactionCurrency, transaction, isDistanceRequest, customUnitRateID, currency]); // If completing a split expense fails, set didConfirm to false to allow the user to edit the fields again if (isEditingSplitBill && didConfirm) { @@ -531,6 +531,18 @@ function MoneyRequestConfirmationList({ ], ); + const shouldDisableParticipant = (participant: Participant): boolean => { + if (ReportUtils.isDraftReport(participant.reportID)) { + return true; + } + + if (!participant.isInvoiceRoom && !participant.isPolicyExpenseChat && !participant.isSelfDM && ReportUtils.isOptimisticPersonalDetail(participant.accountID ?? -1)) { + return true; + } + + return false; + }; + const sections = useMemo(() => { const options: Array> = []; if (isTypeSplit) { @@ -553,7 +565,7 @@ function MoneyRequestConfirmationList({ const formattedSelectedParticipants = selectedParticipants.map((participant) => ({ ...participant, isSelected: false, - isDisabled: !participant.isInvoiceRoom && !participant.isPolicyExpenseChat && !participant.isSelfDM && ReportUtils.isOptimisticPersonalDetail(participant.accountID ?? -1), + isInteractive: !shouldDisableParticipant(participant), })); options.push({ title: translate('common.to'), @@ -603,7 +615,7 @@ function MoneyRequestConfirmationList({ } IOU.setMoneyRequestCategory(transactionID, enabledCategories[0].name); // Keep 'transaction' out to ensure that we autoselect the option only once - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps }, [shouldShowCategories, policyCategories, isCategoryRequired]); // Auto select the tag if there is only one enabled tag and it is required @@ -621,7 +633,7 @@ function MoneyRequestConfirmationList({ IOU.setMoneyRequestTag(transactionID, updatedTagsString); } // Keep 'transaction' out to ensure that we autoselect the option only once - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps }, [policyTagLists, policyTags]); /** @@ -814,6 +826,7 @@ function MoneyRequestConfirmationList({ isTypeInvoice={isTypeInvoice} onToggleBillable={onToggleBillable} policy={policy} + policyTags={policyTags} policyTagLists={policyTagLists} rate={rate} receiptFilename={receiptFilename} diff --git a/src/components/MoneyRequestConfirmationListFooter.tsx b/src/components/MoneyRequestConfirmationListFooter.tsx index cda43938a18f..8dfff6466ab9 100644 --- a/src/components/MoneyRequestConfirmationListFooter.tsx +++ b/src/components/MoneyRequestConfirmationListFooter.tsx @@ -117,6 +117,9 @@ type MoneyRequestConfirmationListFooterProps = { /** The policy */ policy: OnyxEntry; + /** The policy tag lists */ + policyTags: OnyxEntry; + /** The policy tag lists */ policyTagLists: Array>; @@ -193,6 +196,7 @@ function MoneyRequestConfirmationListFooter({ isTypeInvoice, onToggleBillable, policy, + policyTags, policyTagLists, rate, receiptFilename, @@ -226,6 +230,7 @@ function MoneyRequestConfirmationListFooter({ // A flag for showing the tags field // TODO: remove the !isTypeInvoice from this condition after BE supports tags for invoices: https://github.com/Expensify/App/issues/41281 const shouldShowTags = useMemo(() => isPolicyExpenseChat && OptionsListUtils.hasEnabledTags(policyTagLists) && !isTypeInvoice, [isPolicyExpenseChat, isTypeInvoice, policyTagLists]); + const isMultilevelTags = useMemo(() => PolicyUtils.isMultiLevelTags(policyTags), [policyTags]); const senderWorkspace = useMemo(() => { const senderWorkspaceParticipant = selectedParticipants.find((participant) => participant.isSender); @@ -437,8 +442,9 @@ function MoneyRequestConfirmationListFooter({ shouldShow: shouldShowCategories, isSupplementary: action === CONST.IOU.ACTION.CATEGORIZE ? false : !isCategoryRequired, }, - ...policyTagLists.map(({name, required}, index) => { + ...policyTagLists.map(({name, required, tags}, index) => { const isTagRequired = required ?? false; + const shouldShow = shouldShowTags && (!isMultilevelTags || OptionsListUtils.hasEnabledOptions(tags)); return { item: ( ), - shouldShow: shouldShowTags, + shouldShow, isSupplementary: !isTagRequired, }; }), diff --git a/src/components/MoneyRequestHeader.tsx b/src/components/MoneyRequestHeader.tsx index d55d3cc19fe9..b30e9da50701 100644 --- a/src/components/MoneyRequestHeader.tsx +++ b/src/components/MoneyRequestHeader.tsx @@ -1,5 +1,5 @@ import type {ReactNode} from 'react'; -import React, {useCallback, useEffect, useRef, useState} from 'react'; +import React, {useCallback, useEffect, useState} from 'react'; import {View} from 'react-native'; import {useOnyx} from 'react-native-onyx'; import type {OnyxEntry} from 'react-native-onyx'; @@ -7,7 +7,6 @@ import useLocalize from '@hooks/useLocalize'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; -import * as HeaderUtils from '@libs/HeaderUtils'; import Navigation from '@libs/Navigation/Navigation'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; import * as ReportUtils from '@libs/ReportUtils'; @@ -15,15 +14,12 @@ import * as TransactionUtils from '@libs/TransactionUtils'; import variables from '@styles/variables'; import * as IOU from '@userActions/IOU'; import * as TransactionActions from '@userActions/Transaction'; -import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import type {Route} from '@src/ROUTES'; import type {Policy, Report, ReportAction} from '@src/types/onyx'; import type IconAsset from '@src/types/utils/IconAsset'; import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; import Button from './Button'; -import ConfirmModal from './ConfirmModal'; import HeaderWithBackButton from './HeaderWithBackButton'; import Icon from './Icon'; import * as Expensicons from './Icon/Expensicons'; @@ -56,43 +52,21 @@ function MoneyRequestHeader({report, parentReportAction, policy, shouldUseNarrow }`, ); const [transactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS); - const [session] = useOnyx(ONYXKEYS.SESSION); const [dismissedHoldUseExplanation, dismissedHoldUseExplanationResult] = useOnyx(ONYXKEYS.NVP_DISMISSED_HOLD_USE_EXPLANATION, {initialValue: true}); const isLoadingHoldUseExplained = isLoadingOnyxValue(dismissedHoldUseExplanationResult); const styles = useThemeStyles(); const theme = useTheme(); const {translate} = useLocalize(); - const [isDeleteModalVisible, setIsDeleteModalVisible] = useState(false); const [shouldShowHoldMenu, setShouldShowHoldMenu] = useState(false); const isSelfDMTrackExpenseReport = ReportUtils.isTrackExpenseReport(report) && ReportUtils.isSelfDM(parentReport); const moneyRequestReport = !isSelfDMTrackExpenseReport ? parentReport : undefined; - const isSettled = ReportUtils.isSettled(moneyRequestReport?.reportID); - const isApproved = ReportUtils.isReportApproved(moneyRequestReport); const isDraft = ReportUtils.isOpenExpenseReport(moneyRequestReport); const isOnHold = TransactionUtils.isOnHold(transaction); const isDuplicate = TransactionUtils.isDuplicate(transaction?.transactionID ?? ''); - const {isSmallScreenWidth, windowWidth} = useWindowDimensions(); + const {isSmallScreenWidth} = useWindowDimensions(); - const navigateBackToAfterDelete = useRef(); - - // Only the requestor can take delete the request, admins can only edit it. - const isActionOwner = typeof parentReportAction?.actorAccountID === 'number' && typeof session?.accountID === 'number' && parentReportAction.actorAccountID === session?.accountID; - const isPolicyAdmin = policy?.role === CONST.POLICY.ROLE.ADMIN; - const isApprover = ReportUtils.isMoneyRequestReport(moneyRequestReport) && moneyRequestReport?.managerID !== null && session?.accountID === moneyRequestReport?.managerID; const hasAllPendingRTERViolations = TransactionUtils.allHavePendingRTERViolation([transaction?.transactionID ?? '-1']); const shouldShowMarkAsCashButton = isDraft && hasAllPendingRTERViolations; - const deleteTransaction = useCallback(() => { - if (parentReportAction) { - const iouTransactionID = ReportActionsUtils.isMoneyRequestAction(parentReportAction) ? ReportActionsUtils.getOriginalMessage(parentReportAction)?.IOUTransactionID ?? '-1' : '-1'; - if (ReportActionsUtils.isTrackExpenseAction(parentReportAction)) { - navigateBackToAfterDelete.current = IOU.deleteTrackExpense(parentReport?.reportID ?? '-1', iouTransactionID, parentReportAction, true); - } else { - navigateBackToAfterDelete.current = IOU.deleteMoneyRequest(iouTransactionID, parentReportAction, true); - } - } - - setIsDeleteModalVisible(false); - }, [parentReport?.reportID, parentReportAction, setIsDeleteModalVisible]); const markAsCash = useCallback(() => { TransactionActions.markAsCash(transaction?.transactionID ?? '-1', report.reportID); @@ -100,23 +74,6 @@ function MoneyRequestHeader({report, parentReportAction, policy, shouldUseNarrow const isScanning = TransactionUtils.hasReceipt(transaction) && TransactionUtils.isReceiptBeingScanned(transaction); - const isDeletedParentAction = ReportActionsUtils.isDeletedAction(parentReportAction); - const canHoldOrUnholdRequest = !isSettled && !isApproved && !isDeletedParentAction && !ReportUtils.isArchivedRoom(parentReport); - - // If the report supports adding transactions to it, then it also supports deleting transactions from it. - const canDeleteRequest = isActionOwner && (ReportUtils.canAddOrDeleteTransactions(moneyRequestReport) || isSelfDMTrackExpenseReport) && !isDeletedParentAction; - - const changeMoneyRequestStatus = () => { - const iouTransactionID = ReportActionsUtils.isMoneyRequestAction(parentReportAction) ? ReportActionsUtils.getOriginalMessage(parentReportAction)?.IOUTransactionID ?? '-1' : '-1'; - - if (isOnHold) { - IOU.unholdRequest(iouTransactionID, report?.reportID); - } else { - const activeRoute = encodeURIComponent(Navigation.getActiveRouteWithoutParams()); - Navigation.navigate(ROUTES.MONEY_REQUEST_HOLD_REASON.getRoute(policy?.type ?? CONST.POLICY.TYPE.PERSONAL, iouTransactionID, report?.reportID, activeRoute)); - } - }; - const getStatusIcon: (src: IconAsset) => ReactNode = (src) => ( { - if (canDeleteRequest) { - return; - } - - setIsDeleteModalVisible(false); - }, [canDeleteRequest]); - - const threeDotsMenuItems = [HeaderUtils.getPinMenuItem(report)]; - if (canHoldOrUnholdRequest) { - const isRequestIOU = parentReport?.type === 'iou'; - const isHoldCreator = ReportUtils.isHoldCreator(transaction, report?.reportID) && isRequestIOU; - const isTrackExpenseReport = ReportUtils.isTrackExpenseReport(report); - const canModifyStatus = !isTrackExpenseReport && (isPolicyAdmin || isActionOwner || isApprover); - if (isOnHold && !isDuplicate && (isHoldCreator || (!isRequestIOU && canModifyStatus))) { - threeDotsMenuItems.push({ - icon: Expensicons.Stopwatch, - text: translate('iou.unholdExpense'), - onSelected: () => changeMoneyRequestStatus(), - }); - } - if (!isOnHold && (isRequestIOU || canModifyStatus) && !isScanning) { - threeDotsMenuItems.push({ - icon: Expensicons.Stopwatch, - text: translate('iou.hold'), - onSelected: () => changeMoneyRequestStatus(), - }); - } - } - useEffect(() => { if (isLoadingHoldUseExplained) { return; @@ -199,14 +126,6 @@ function MoneyRequestHeader({report, parentReportAction, policy, shouldUseNarrow IOU.dismissHoldUseExplanation(); }; - if (canDeleteRequest) { - threeDotsMenuItems.push({ - icon: Expensicons.Trashcan, - text: translate('reportActionContextMenu.deleteAction', {action: parentReportAction}), - onSelected: () => setIsDeleteModalVisible(true), - }); - } - return ( <> @@ -215,9 +134,6 @@ function MoneyRequestHeader({report, parentReportAction, policy, shouldUseNarrow shouldShowReportAvatarWithDisplay shouldEnableDetailPageNavigation shouldShowPinButton={false} - shouldShowThreeDotsButton - threeDotsMenuItems={threeDotsMenuItems} - threeDotsAnchorPosition={styles.threeDotsPopoverOffsetNoCloseButton(windowWidth)} report={{ ...report, ownerAccountID: parentReport?.ownerAccountID, @@ -281,18 +197,6 @@ function MoneyRequestHeader({report, parentReportAction, policy, shouldUseNarrow )} - setIsDeleteModalVisible(false)} - onModalHide={() => ReportUtils.navigateBackAfterDeleteTransaction(navigateBackToAfterDelete.current)} - prompt={translate('iou.deleteConfirmation')} - confirmText={translate('common.delete')} - cancelText={translate('common.cancel')} - danger - shouldEnableNewFocusManagement - /> {isSmallScreenWidth && shouldShowHoldMenu && ( ; /** 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; }; @@ -119,6 +121,7 @@ function MultiGestureCanvas({ const reset = useWorkletCallback((animated: boolean, callback?: () => void) => { stopAnimation(); + // eslint-disable-next-line react-compiler/react-compiler offsetX.value = 0; offsetY.value = 0; pinchScale.value = 1; @@ -242,11 +245,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..fa27e48eea4c 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]); @@ -113,6 +116,7 @@ const usePanGesture = ({ // If the (absolute) velocity is 0, we don't need to run an animation if (Math.abs(panVelocityX.value) !== 0) { // Phase out the pan animation + // eslint-disable-next-line react-compiler/react-compiler offsetX.value = withDecay({ velocity: panVelocityX.value, clamp: [horizontalBoundaries.min, horizontalBoundaries.max], @@ -207,7 +211,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/MultiGestureCanvas/usePinchGesture.ts b/src/components/MultiGestureCanvas/usePinchGesture.ts index 87d3bdada6a2..46a5e28e5732 100644 --- a/src/components/MultiGestureCanvas/usePinchGesture.ts +++ b/src/components/MultiGestureCanvas/usePinchGesture.ts @@ -68,6 +68,7 @@ const usePinchGesture = ({ useAnimatedReaction( () => [pinchTranslateX.value, pinchTranslateY.value, pinchBounceTranslateX.value, pinchBounceTranslateY.value], ([translateX, translateY, bounceX, bounceY]) => { + // eslint-disable-next-line react-compiler/react-compiler totalPinchTranslateX.value = translateX + bounceX; totalPinchTranslateY.value = translateY + bounceY; }, diff --git a/src/components/MultiGestureCanvas/useTapGestures.ts b/src/components/MultiGestureCanvas/useTapGestures.ts index f550e93d6be2..e4bb02bd5d34 100644 --- a/src/components/MultiGestureCanvas/useTapGestures.ts +++ b/src/components/MultiGestureCanvas/useTapGestures.ts @@ -111,6 +111,7 @@ const useTapGestures = ({ offsetAfterZooming.y = 0; } + // eslint-disable-next-line react-compiler/react-compiler offsetX.value = withSpring(offsetAfterZooming.x, SPRING_CONFIG); offsetY.value = withSpring(offsetAfterZooming.y, SPRING_CONFIG); zoomScale.value = withSpring(doubleTapScale, SPRING_CONFIG, callback); 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/Onfido/BaseOnfidoWeb.tsx b/src/components/Onfido/BaseOnfidoWeb.tsx index 2e61d048f060..7ed05a8a9462 100644 --- a/src/components/Onfido/BaseOnfidoWeb.tsx +++ b/src/components/Onfido/BaseOnfidoWeb.tsx @@ -140,7 +140,7 @@ function Onfido({sdkToken, onSuccess, onError, onUserExit}: OnfidoProps, ref: Fo window.addEventListener('userAnalyticsEvent', logOnFidoEvent); return () => window.removeEventListener('userAnalyticsEvent', logOnFidoEvent); // Onfido should be initialized only once on mount - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps }, []); return ( diff --git a/src/components/Onfido/index.native.tsx b/src/components/Onfido/index.native.tsx index fd681e610f86..c6eb9c8868ee 100644 --- a/src/components/Onfido/index.native.tsx +++ b/src/components/Onfido/index.native.tsx @@ -88,7 +88,7 @@ function Onfido({sdkToken, onUserExit, onSuccess, onError}: OnfidoProps) { } }); // Onfido should be initialized only once on mount - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps }, []); return ; diff --git a/src/components/OpacityView.tsx b/src/components/OpacityView.tsx index 41ab148bd7f2..d4a5c05167a0 100644 --- a/src/components/OpacityView.tsx +++ b/src/components/OpacityView.tsx @@ -36,6 +36,7 @@ function OpacityView({shouldDim, children, style = [], dimmingValue = variables. React.useEffect(() => { if (shouldDim) { + // eslint-disable-next-line react-compiler/react-compiler opacity.value = withTiming(dimmingValue, {duration: 50}); } else { opacity.value = withTiming(1, {duration: 50}); diff --git a/src/components/OptionListContextProvider.tsx b/src/components/OptionListContextProvider.tsx index 21f8bb3de097..f098188de270 100644 --- a/src/components/OptionListContextProvider.tsx +++ b/src/components/OptionListContextProvider.tsx @@ -73,7 +73,7 @@ function OptionsListContextProvider({reports, children}: OptionsListProviderProp return newOptions; }); - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps }, [reports]); /** @@ -124,7 +124,7 @@ function OptionsListContextProvider({reports, children}: OptionsListProviderProp }); // This effect is used to update the options list when personal details change so we ignore all dependencies except personalDetails - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps }, [personalDetails]); const loadOptions = useCallback(() => { diff --git a/src/components/PDFView/index.tsx b/src/components/PDFView/index.tsx index a64a676cb97a..2edf699affab 100644 --- a/src/components/PDFView/index.tsx +++ b/src/components/PDFView/index.tsx @@ -68,7 +68,7 @@ function PDFView({onToggleKeyboard, fileName, onPress, isFocused, sourceURL, max useEffect(() => { retrieveCanvasLimits(); // This rule needs to be applied so that this effect is executed only when the component is mounted - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps }, []); useEffect(() => { diff --git a/src/components/Picker/BasePicker.tsx b/src/components/Picker/BasePicker.tsx index 1c337e024116..ddd6cd544f3e 100644 --- a/src/components/Picker/BasePicker.tsx +++ b/src/components/Picker/BasePicker.tsx @@ -61,7 +61,7 @@ function BasePicker( // so they don't have to spend extra time selecting the only possible value. onInputChange(items[0].value, 0); - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps }, [items]); const context = useScrollContext(); diff --git a/src/components/PlaidLink/index.native.tsx b/src/components/PlaidLink/index.native.tsx index 24ab75eb62b7..37b598303c3a 100644 --- a/src/components/PlaidLink/index.native.tsx +++ b/src/components/PlaidLink/index.native.tsx @@ -31,7 +31,7 @@ function PlaidLink({token, onSuccess = () => {}, onExit = () => {}, onEvent}: Pl }; // We generally do not need to include the token as a dependency here as it is only provided once via props and should not change - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps }, []); return null; } diff --git a/src/components/PopoverMenu.tsx b/src/components/PopoverMenu.tsx index 36e33fdda799..0f97a3c4414f 100644 --- a/src/components/PopoverMenu.tsx +++ b/src/components/PopoverMenu.tsx @@ -109,16 +109,18 @@ function PopoverMenu({ const selectedItemIndex = useRef(null); const [currentMenuItems, setCurrentMenuItems] = useState(menuItems); + const currentMenuItemsFocusedIndex = currentMenuItems?.findIndex((option) => option.isSelected); const [enteredSubMenuIndexes, setEnteredSubMenuIndexes] = useState([]); - const [focusedIndex, setFocusedIndex] = useArrowKeyFocusManager({initialFocusedIndex: -1, maxIndex: currentMenuItems.length - 1, isActive: isVisible}); + const [focusedIndex, setFocusedIndex] = useArrowKeyFocusManager({initialFocusedIndex: currentMenuItemsFocusedIndex, maxIndex: currentMenuItems.length - 1, isActive: isVisible}); const selectItem = (index: number) => { const selectedItem = currentMenuItems[index]; if (selectedItem?.subMenuItems) { setCurrentMenuItems([...selectedItem.subMenuItems]); setEnteredSubMenuIndexes([...enteredSubMenuIndexes, index]); - setFocusedIndex(-1); + const selectedSubMenuItemIndex = selectedItem?.subMenuItems.findIndex((option) => option.isSelected); + setFocusedIndex(selectedSubMenuItemIndex); } else { selectedItemIndex.current = index; onItemSelected(selectedItem, index); @@ -225,6 +227,7 @@ function PopoverMenu({ iconFill={item.iconFill} contentFit={item.contentFit} title={item.text} + titleStyle={item.titleStyle} shouldCheckActionAllowedOnPress={false} description={item.description} numberOfLinesDescription={item.numberOfLinesDescription} @@ -247,6 +250,8 @@ function PopoverMenu({ shouldForceRenderingTooltipLeft={item.shouldForceRenderingTooltipLeft} tooltipWrapperStyle={item.tooltipWrapperStyle} renderTooltipContent={item.renderTooltipContent} + numberOfLinesTitle={item.numberOfLinesTitle} + interactive={item.interactive} /> ))} diff --git a/src/components/PopoverWithoutOverlay/index.tsx b/src/components/PopoverWithoutOverlay/index.tsx index 06478b468e1e..bcead42a64f2 100644 --- a/src/components/PopoverWithoutOverlay/index.tsx +++ b/src/components/PopoverWithoutOverlay/index.tsx @@ -69,7 +69,7 @@ function PopoverWithoutOverlay( removeOnClose(); }; // We want this effect to run strictly ONLY when isVisible prop changes - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps }, [isVisible]); const { diff --git a/src/components/Pressable/GenericPressable/BaseGenericPressable.tsx b/src/components/Pressable/GenericPressable/BaseGenericPressable.tsx index 377007d40c54..5237ff486631 100644 --- a/src/components/Pressable/GenericPressable/BaseGenericPressable.tsx +++ b/src/components/Pressable/GenericPressable/BaseGenericPressable.tsx @@ -36,6 +36,7 @@ function GenericPressable( onPressOut, accessible = true, fullDisabled = false, + interactive = true, ...rest }: PressableProps, ref: PressableRef, @@ -67,6 +68,9 @@ function GenericPressable( * Returns the cursor style based on the state of Pressable */ const cursorStyle = useMemo(() => { + if (!interactive) { + return styles.cursorDefault; + } if (shouldUseDisabledCursor) { return styles.cursorDisabled; } @@ -74,7 +78,7 @@ function GenericPressable( return styles.cursorText; } return styles.cursorPointer; - }, [styles, shouldUseDisabledCursor, rest.accessibilityRole, rest.role]); + }, [styles, shouldUseDisabledCursor, rest.accessibilityRole, rest.role, interactive]); const onLongPressHandler = useCallback( (event: GestureResponderEvent) => { @@ -98,7 +102,7 @@ function GenericPressable( const onPressHandler = useCallback( (event?: GestureResponderEvent | KeyboardEvent) => { - if (isDisabled) { + if (isDisabled || !interactive) { return; } if (!onPress) { @@ -113,7 +117,7 @@ function GenericPressable( } return onPress(event); }, - [shouldUseHapticsOnPress, onPress, nextFocusRef, ref, isDisabled], + [shouldUseHapticsOnPress, onPress, nextFocusRef, ref, isDisabled, interactive], ); const voidOnPressHandler = useCallback( diff --git a/src/components/Pressable/GenericPressable/types.ts b/src/components/Pressable/GenericPressable/types.ts index 26a2fea42d94..61cb6db8ee76 100644 --- a/src/components/Pressable/GenericPressable/types.ts +++ b/src/components/Pressable/GenericPressable/types.ts @@ -142,6 +142,12 @@ type PressableProps = RNPressableProps & * Specifies if the pressable responder should be disabled */ fullDisabled?: boolean; + + /** + * Whether the menu item should be interactive at all + * e.g., show disabled cursor when disabled + */ + interactive?: boolean; }; type PressableRef = ForwardedRef; 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/Reactions/EmojiReactionBubble.tsx b/src/components/Reactions/EmojiReactionBubble.tsx index 26a14b078b6f..6fa4ee8bb6fb 100644 --- a/src/components/Reactions/EmojiReactionBubble.tsx +++ b/src/components/Reactions/EmojiReactionBubble.tsx @@ -7,7 +7,6 @@ import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import type {ReactionListEvent} from '@pages/home/ReportScreenContext'; import CONST from '@src/CONST'; -import getEmojiReactionBubbleTextOffsetStyle from './getEmojiReactionBubbleTextOffsetStyle'; type EmojiReactionBubbleProps = { /** @@ -83,7 +82,7 @@ function EmojiReactionBubble( accessible dataSet={{[CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT]: true}} > - {emojiCodes.join('')} + {emojiCodes.join('')} {count > 0 && {count}} ); diff --git a/src/components/Reactions/getEmojiReactionBubbleTextOffsetStyle/index.ios.ts b/src/components/Reactions/getEmojiReactionBubbleTextOffsetStyle/index.ios.ts deleted file mode 100644 index 9f7fb248a103..000000000000 --- a/src/components/Reactions/getEmojiReactionBubbleTextOffsetStyle/index.ios.ts +++ /dev/null @@ -1,6 +0,0 @@ -function getEmojiReactionBubbleTextOffsetStyle() { - // https://github.com/Expensify/App/issues/36739 - return {transform: [{translateY: 2}]}; -} - -export default getEmojiReactionBubbleTextOffsetStyle; diff --git a/src/components/Reactions/getEmojiReactionBubbleTextOffsetStyle/index.ts b/src/components/Reactions/getEmojiReactionBubbleTextOffsetStyle/index.ts deleted file mode 100644 index 1e459554789c..000000000000 --- a/src/components/Reactions/getEmojiReactionBubbleTextOffsetStyle/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -function getEmojiReactionBubbleTextOffsetStyle() { - return {transform: [{translateY: 0}]}; -} - -export default getEmojiReactionBubbleTextOffsetStyle; 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/MoneyRequestPreview/MoneyRequestPreviewContent.tsx b/src/components/ReportActionItem/MoneyRequestPreview/MoneyRequestPreviewContent.tsx index 9e31dc110579..896432708aff 100644 --- a/src/components/ReportActionItem/MoneyRequestPreview/MoneyRequestPreviewContent.tsx +++ b/src/components/ReportActionItem/MoneyRequestPreview/MoneyRequestPreviewContent.tsx @@ -1,9 +1,11 @@ +import type {RouteProp} from '@react-navigation/native'; import {useRoute} from '@react-navigation/native'; import lodashSortBy from 'lodash/sortBy'; import truncate from 'lodash/truncate'; import React, {useMemo} from 'react'; import {View} from 'react-native'; import type {GestureResponderEvent} from 'react-native'; +import {useOnyx} from 'react-native-onyx'; import type {OnyxEntry} from 'react-native-onyx'; import Button from '@components/Button'; import Icon from '@components/Icon'; @@ -27,6 +29,8 @@ import ControlSelection from '@libs/ControlSelection'; import * as CurrencyUtils from '@libs/CurrencyUtils'; import * as DeviceCapabilities from '@libs/DeviceCapabilities'; import * as IOUUtils from '@libs/IOUUtils'; +import Navigation from '@libs/Navigation/Navigation'; +import type {TransactionDuplicateNavigatorParamList} from '@libs/Navigation/types'; import * as OptionsListUtils from '@libs/OptionsListUtils'; import * as ReceiptUtils from '@libs/ReceiptUtils'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; @@ -41,6 +45,7 @@ import * as Report from '@userActions/Report'; import * as Transaction from '@userActions/Transaction'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; import type {OriginalMessageIOU} from '@src/types/onyx/OriginalMessage'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; @@ -72,7 +77,7 @@ function MoneyRequestPreviewContent({ const StyleUtils = useStyleUtils(); const {translate} = useLocalize(); const {windowWidth} = useWindowDimensions(); - const route = useRoute(); + const route = useRoute>(); const {shouldUseNarrowLayout} = useResponsiveLayout(); const sessionAccountID = session?.accountID; @@ -126,6 +131,9 @@ function MoneyRequestPreviewContent({ const showCashOrCard = isCardTransaction ? translate('iou.card') : translate('iou.cash'); const shouldShowHoldMessage = !(isSettled && !isSettlementOrApprovalPartial) && isOnHold; + const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${route.params?.threadReportID}`); + const parentReportAction = ReportActionsUtils.getReportAction(report?.parentReportID ?? '', report?.parentReportActionID ?? ''); + const reviewingTransactionID = ReportActionsUtils.isMoneyRequestAction(parentReportAction) ? ReportActionsUtils.getOriginalMessage(parentReportAction)?.IOUTransactionID ?? '-1' : '-1'; // Get transaction violations for given transaction id from onyx, find duplicated transactions violations and get duplicates const duplicates = useMemo( () => @@ -264,6 +272,29 @@ function MoneyRequestPreviewContent({ [shouldShowSplitShare, isPolicyExpenseChat, action.actorAccountID, participantAccountIDs.length, transaction?.comment?.splits, requestAmount, requestCurrency, sessionAccountID], ); + const navigateToReviewFields = () => { + const comparisonResult = TransactionUtils.compareDuplicateTransactionFields(reviewingTransactionID); + const allTransactionIDsDuplicates = [reviewingTransactionID, ...duplicates].filter((id) => id !== transaction?.transactionID); + Transaction.setReviewDuplicatesKey({...comparisonResult.keep, duplicates: allTransactionIDsDuplicates, transactionID: transaction?.transactionID ?? ''}); + if ('merchant' in comparisonResult.change) { + Navigation.navigate(ROUTES.TRANSACTION_DUPLICATE_REVIEW_MERCHANT_PAGE.getRoute(route.params?.threadReportID)); + } else if ('category' in comparisonResult.change) { + Navigation.navigate(ROUTES.TRANSACTION_DUPLICATE_REVIEW_CATEGORY_PAGE.getRoute(route.params?.threadReportID)); + } else if ('tag' in comparisonResult.change) { + Navigation.navigate(ROUTES.TRANSACTION_DUPLICATE_REVIEW_TAG_PAGE.getRoute(route.params?.threadReportID)); + } else if ('description' in comparisonResult.change) { + Navigation.navigate(ROUTES.TRANSACTION_DUPLICATE_REVIEW_DESCRIPTION_PAGE.getRoute(route.params?.threadReportID)); + } else if ('taxCode' in comparisonResult.change) { + Navigation.navigate(ROUTES.TRANSACTION_DUPLICATE_REVIEW_TAX_CODE_PAGE.getRoute(route.params?.threadReportID)); + } else if ('billable' in comparisonResult.change) { + Navigation.navigate(ROUTES.TRANSACTION_DUPLICATE_REVIEW_BILLABLE_PAGE.getRoute(route.params?.threadReportID)); + } else if ('reimbursable' in comparisonResult.change) { + Navigation.navigate(ROUTES.TRANSACTION_DUPLICATE_REVIEW_REIMBURSABLE_PAGE.getRoute(route.params?.threadReportID)); + } else { + // Navigation to confirm screen will be done in seperate PR + } + }; + const childContainer = ( { - Transaction.setReviewDuplicatesKey(transaction?.transactionID ?? '', duplicates); - }} + onPress={navigateToReviewFields} /> )} diff --git a/src/components/ReportActionItem/ReportPreview.tsx b/src/components/ReportActionItem/ReportPreview.tsx index c796a267fd01..4a145d4e79e9 100644 --- a/src/components/ReportActionItem/ReportPreview.tsx +++ b/src/components/ReportActionItem/ReportPreview.tsx @@ -121,7 +121,7 @@ function ReportPreview({ hasNonReimbursableTransactions: ReportUtils.hasNonReimbursableTransactions(iouReportID), }), // When transactions get updated these status may have changed, so that is a case where we also want to run this. - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps [transactions, iouReportID, action], ); @@ -454,6 +454,7 @@ function ReportPreview({ paymentType={paymentType} chatReport={chatReport} moneyRequestReport={iouReport} + transactionCount={numberOfRequests} /> )} diff --git a/src/components/ScreenWrapper.tsx b/src/components/ScreenWrapper.tsx index 1d5d65d9874d..f845cfda3638 100644 --- a/src/components/ScreenWrapper.tsx +++ b/src/components/ScreenWrapper.tsx @@ -211,7 +211,7 @@ function ScreenWrapper( } }; // Rule disabled because this effect is only for component did mount & will component unmount lifecycle event - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps }, []); const isAvoidingViewportScroll = useTackInputFocus(shouldEnableMaxHeight && shouldAvoidScrollOnVirtualViewport && Browser.isMobileWebKit()); diff --git a/src/components/Search/SearchListWithHeader.tsx b/src/components/Search/SearchListWithHeader.tsx new file mode 100644 index 000000000000..48d9a2b4ae3a --- /dev/null +++ b/src/components/Search/SearchListWithHeader.tsx @@ -0,0 +1,124 @@ +import type {ForwardedRef} from 'react'; +import React, {forwardRef, useEffect, useMemo, useState} from 'react'; +import SelectionList from '@components/SelectionList'; +import type {BaseSelectionListProps, ReportListItemType, SelectionListHandle, TransactionListItemType} from '@components/SelectionList/types'; +import * as SearchUtils from '@libs/SearchUtils'; +import CONST from '@src/CONST'; +import type {SearchDataTypes, SearchQuery} from '@src/types/onyx/SearchResults'; +import SearchPageHeader from './SearchPageHeader'; +import type {SelectedTransactionInfo, SelectedTransactions} from './types'; + +type SearchListWithHeaderProps = Omit, 'onSelectAll' | 'onCheckboxPress' | 'sections'> & { + query: SearchQuery; + hash: number; + data: TransactionListItemType[] | ReportListItemType[]; + searchType: SearchDataTypes; +}; + +function mapTransactionItemToSelectedEntry(item: TransactionListItemType): [string, SelectedTransactionInfo] { + return [item.keyForList, {isSelected: true, canDelete: item.canDelete, action: item.action}]; +} + +function mapToTransactionItemWithSelectionInfo(item: TransactionListItemType, selectedItems: SelectedTransactions) { + return {...item, isSelected: !!selectedItems[item.keyForList]?.isSelected}; +} + +function mapToItemWithSelectionInfo(item: TransactionListItemType | ReportListItemType, selectedItems: SelectedTransactions) { + return SearchUtils.isTransactionListItemType(item) + ? mapToTransactionItemWithSelectionInfo(item, selectedItems) + : { + ...item, + transactions: item.transactions?.map((tranaction) => mapToTransactionItemWithSelectionInfo(tranaction, selectedItems)), + isSelected: item.transactions.every((transaction) => !!selectedItems[transaction.keyForList]?.isSelected), + }; +} + +function SearchListWithHeader({ListItem, onSelectRow, query, hash, data, searchType, ...props}: SearchListWithHeaderProps, ref: ForwardedRef) { + const [selectedItems, setSelectedItems] = useState({}); + + const clearSelectedItems = () => setSelectedItems({}); + + useEffect(() => { + clearSelectedItems(); + }, [hash]); + + const toggleTransaction = (item: TransactionListItemType | ReportListItemType) => { + if (SearchUtils.isTransactionListItemType(item)) { + if (!item.keyForList) { + return; + } + + setSelectedItems((prev) => { + if (prev[item.keyForList]?.isSelected) { + const {[item.keyForList]: omittedTransaction, ...transactions} = prev; + return transactions; + } + return {...prev, [item.keyForList]: {isSelected: true, canDelete: item.canDelete, action: item.action}}; + }); + + return; + } + + if (item.transactions.every((transaction) => selectedItems[transaction.keyForList]?.isSelected)) { + const reducedSelectedItems: SelectedTransactions = {...selectedItems}; + + item.transactions.forEach((transaction) => { + delete reducedSelectedItems[transaction.keyForList]; + }); + + setSelectedItems(reducedSelectedItems); + return; + } + + setSelectedItems({ + ...selectedItems, + ...Object.fromEntries(item.transactions.map(mapTransactionItemToSelectedEntry)), + }); + }; + + const toggleAllTransactions = () => { + const areItemsOfReportType = searchType === CONST.SEARCH.DATA_TYPES.REPORT; + const flattenedItems = areItemsOfReportType ? (data as ReportListItemType[]).flatMap((item) => item.transactions) : data; + const isAllSelected = flattenedItems.length === Object.keys(selectedItems).length; + + if (isAllSelected) { + clearSelectedItems(); + return; + } + + if (areItemsOfReportType) { + setSelectedItems(Object.fromEntries((data as ReportListItemType[]).flatMap((item) => item.transactions.map(mapTransactionItemToSelectedEntry)))); + + return; + } + + setSelectedItems(Object.fromEntries((data as TransactionListItemType[]).map(mapTransactionItemToSelectedEntry))); + }; + + const sortedSelectedData = useMemo(() => data.map((item) => mapToItemWithSelectionInfo(item, selectedItems)), [data, selectedItems]); + + return ( + <> + + + // eslint-disable-next-line react/jsx-props-no-spreading + {...props} + sections={[{data: sortedSelectedData, isDisabled: false}]} + ListItem={ListItem} + onSelectRow={onSelectRow} + ref={ref} + onCheckboxPress={toggleTransaction} + onSelectAll={toggleAllTransactions} + /> + + ); +} + +SearchListWithHeader.displayName = 'SearchListWithHeader'; + +export default forwardRef(SearchListWithHeader); diff --git a/src/components/Search/SearchPageHeader.tsx b/src/components/Search/SearchPageHeader.tsx new file mode 100644 index 000000000000..8d42f9e6da36 --- /dev/null +++ b/src/components/Search/SearchPageHeader.tsx @@ -0,0 +1,141 @@ +import React, {useCallback} from 'react'; +import ButtonWithDropdownMenu from '@components/ButtonWithDropdownMenu'; +import type {DropdownOption} from '@components/ButtonWithDropdownMenu/types'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import * as Expensicons from '@components/Icon/Expensicons'; +import * as Illustrations from '@components/Icon/Illustrations'; +import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; +import * as SearchActions from '@libs/actions/Search'; +import variables from '@styles/variables'; +import CONST from '@src/CONST'; +import type {SearchQuery} from '@src/types/onyx/SearchResults'; +import type DeepValueOf from '@src/types/utils/DeepValueOf'; +import type IconAsset from '@src/types/utils/IconAsset'; +import type {SelectedTransactions} from './types'; + +type SearchHeaderProps = { + query: SearchQuery; + selectedItems?: SelectedTransactions; + clearSelectedItems?: () => void; + hash: number; +}; + +type SearchHeaderOptionValue = DeepValueOf | undefined; + +function SearchPageHeader({query, selectedItems = {}, hash, clearSelectedItems}: SearchHeaderProps) { + const {translate} = useLocalize(); + const theme = useTheme(); + const styles = useThemeStyles(); + const {isOffline} = useNetwork(); + const {isSmallScreenWidth} = useResponsiveLayout(); + const headerContent: {[key in SearchQuery]: {icon: IconAsset; title: string}} = { + all: {icon: Illustrations.MoneyReceipts, title: translate('common.expenses')}, + shared: {icon: Illustrations.SendMoney, title: translate('common.shared')}, + drafts: {icon: Illustrations.Pencil, title: translate('common.drafts')}, + finished: {icon: Illustrations.CheckmarkCircle, title: translate('common.finished')}, + }; + + const getHeaderButtons = useCallback(() => { + const options: Array> = []; + const selectedItemsKeys = Object.keys(selectedItems ?? []); + + if (selectedItemsKeys.length === 0) { + return null; + } + + const itemsToDelete = selectedItemsKeys.filter((id) => selectedItems[id].canDelete); + + if (itemsToDelete.length > 0) { + options.push({ + icon: Expensicons.Trashcan, + text: translate('search.bulkActions.delete'), + value: CONST.SEARCH.BULK_ACTION_TYPES.DELETE, + onSelected: () => { + clearSelectedItems?.(); + SearchActions.deleteMoneyRequestOnSearch(hash, itemsToDelete); + }, + }); + } + + const itemsToHold = selectedItemsKeys.filter((id) => selectedItems[id].action === CONST.SEARCH.BULK_ACTION_TYPES.HOLD); + + if (itemsToHold.length > 0) { + options.push({ + icon: Expensicons.Stopwatch, + text: translate('search.bulkActions.hold'), + value: CONST.SEARCH.BULK_ACTION_TYPES.HOLD, + onSelected: () => { + clearSelectedItems?.(); + SearchActions.holdMoneyRequestOnSearch(hash, itemsToHold, ''); + }, + }); + } + + const itemsToUnhold = selectedItemsKeys.filter((id) => selectedItems[id].action === CONST.SEARCH.BULK_ACTION_TYPES.UNHOLD); + + if (itemsToUnhold.length > 0) { + options.push({ + icon: Expensicons.Stopwatch, + text: translate('search.bulkActions.unhold'), + value: CONST.SEARCH.BULK_ACTION_TYPES.UNHOLD, + onSelected: () => { + clearSelectedItems?.(); + SearchActions.unholdMoneyRequestOnSearch(hash, itemsToUnhold); + }, + }); + } + + if (options.length === 0) { + const emptyOptionStyle = { + interactive: false, + iconFill: theme.icon, + iconHeight: variables.iconSizeLarge, + iconWidth: variables.iconSizeLarge, + numberOfLinesTitle: 2, + titleStyle: {...styles.colorMuted, ...styles.fontWeightNormal}, + }; + + options.push({ + icon: Expensicons.Exclamation, + text: translate('search.bulkActions.noOptionsAvailable'), + value: undefined, + ...emptyOptionStyle, + }); + } + + return ( + null} + shouldAlwaysShowDropdownMenu + pressOnEnter + buttonSize={CONST.DROPDOWN_BUTTON_SIZE.MEDIUM} + customText={translate('workspace.common.selected', {selectedNumber: selectedItemsKeys.length})} + options={options} + isSplitButton={false} + isDisabled={isOffline} + /> + ); + }, [clearSelectedItems, hash, isOffline, selectedItems, styles.colorMuted, styles.fontWeightNormal, theme.icon, translate]); + + if (isSmallScreenWidth) { + return null; + } + + return ( + + {getHeaderButtons()} + + ); +} + +SearchPageHeader.displayName = 'SearchPageHeader'; + +export default SearchPageHeader; diff --git a/src/components/Search.tsx b/src/components/Search/index.tsx similarity index 83% rename from src/components/Search.tsx rename to src/components/Search/index.tsx index 714993204afb..8445cb3bc72e 100644 --- a/src/components/Search.tsx +++ b/src/components/Search/index.tsx @@ -3,6 +3,9 @@ import type {StackNavigationProp} from '@react-navigation/stack'; import React, {useCallback, useEffect, useRef} from 'react'; import type {OnyxEntry} from 'react-native-onyx'; import {useOnyx} from 'react-native-onyx'; +import SearchTableHeader from '@components/SelectionList/SearchTableHeader'; +import type {ReportListItemType, TransactionListItemType} from '@components/SelectionList/types'; +import TableListItemSkeleton from '@components/Skeletons/TableListItemSkeleton'; import useNetwork from '@hooks/useNetwork'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; @@ -10,8 +13,8 @@ import * as SearchActions from '@libs/actions/Search'; import * as DeviceCapabilities from '@libs/DeviceCapabilities'; import Log from '@libs/Log'; import * as ReportUtils from '@libs/ReportUtils'; -import * as SearchUtils from '@libs/SearchUtils'; import type {SearchColumnType, SortOrder} from '@libs/SearchUtils'; +import * as SearchUtils from '@libs/SearchUtils'; import Navigation from '@navigation/Navigation'; import type {AuthScreensParamList} from '@navigation/types'; import EmptySearchView from '@pages/Search/EmptySearchView'; @@ -19,14 +22,11 @@ import variables from '@styles/variables'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import type {SearchQuery} from '@src/types/onyx/SearchResults'; import type SearchResults from '@src/types/onyx/SearchResults'; -import {isEmptyObject} from '@src/types/utils/EmptyObject'; +import type {SearchDataTypes, SearchQuery} from '@src/types/onyx/SearchResults'; import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; -import SelectionList from './SelectionList'; -import SearchTableHeader from './SelectionList/SearchTableHeader'; -import type {ReportListItemType, TransactionListItemType} from './SelectionList/types'; -import TableListItemSkeleton from './Skeletons/TableListItemSkeleton'; +import SearchListWithHeader from './SearchListWithHeader'; +import SearchPageHeader from './SearchPageHeader'; type SearchProps = { query: SearchQuery; @@ -41,11 +41,6 @@ const reportItemTransactionHeight = 52; const listItemPadding = 12; // this is equivalent to 'mb3' on every transaction/report list item const searchHeaderHeight = 54; -function isTransactionListItemType(item: TransactionListItemType | ReportListItemType): item is TransactionListItemType { - const transactionListItem = item as TransactionListItemType; - return transactionListItem.transactionID !== undefined; -} - function Search({query, policyIDs, sortBy, sortOrder}: SearchProps) { const {isOffline} = useNetwork(); const styles = useThemeStyles(); @@ -55,7 +50,7 @@ function Search({query, policyIDs, sortBy, sortOrder}: SearchProps) { const getItemHeight = useCallback( (item: TransactionListItemType | ReportListItemType) => { - if (isTransactionListItemType(item)) { + if (SearchUtils.isTransactionListItemType(item)) { return isLargeScreenWidth ? variables.optionRowHeight + listItemPadding : transactionItemMobileHeight + listItemPadding; } @@ -89,30 +84,46 @@ function Search({query, policyIDs, sortBy, sortOrder}: SearchProps) { } SearchActions.search({hash, query, policyIDs, offset: 0, sortBy, sortOrder}); - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps }, [hash, isOffline]); 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 ; + return ( + <> + + + + ); } if (shouldShowEmptyState) { - return ; + return ( + <> + + + + ); } const openReport = (item: TransactionListItemType | ReportListItemType) => { - let reportID = isTransactionListItemType(item) ? item.transactionThreadReportID : item.reportID; + let reportID = SearchUtils.isTransactionListItemType(item) ? item.transactionThreadReportID : item.reportID; if (!reportID) { return; } // If we're trying to open a legacy transaction without a transaction thread, let's create the thread and navigate the user - if (isTransactionListItemType(item) && reportID === '0' && item.moneyRequestReportActionID) { + if (SearchUtils.isTransactionListItemType(item) && reportID === '0' && item.moneyRequestReportActionID) { reportID = ReportUtils.generateReportID(); SearchActions.createTransactionThread(hash, item.transactionID, reportID, item.moneyRequestReportActionID); } @@ -152,7 +163,11 @@ function Search({query, policyIDs, sortBy, sortOrder}: SearchProps) { const shouldShowYear = SearchUtils.shouldShowYear(searchResults?.data); return ( - + } + canSelectMultiple={isLargeScreenWidth} customListHeaderHeight={searchHeaderHeight} // To enhance the smoothness of scrolling and minimize the risk of encountering blank spaces during scrolling, // we have configured a larger windowSize and a longer delay between batch renders. @@ -177,8 +193,7 @@ function Search({query, policyIDs, sortBy, sortOrder}: SearchProps) { windowSize={111} updateCellsBatchingPeriod={200} ListItem={ListItem} - sections={[{data: sortedData, isDisabled: false}]} - onSelectRow={(item) => openReport(item)} + onSelectRow={openReport} getItemHeight={getItemHeight} shouldDebounceRowSelect shouldPreventDefaultFocusOnSelectRow={!DeviceCapabilities.canUseTouchScreen()} diff --git a/src/components/Search/types.ts b/src/components/Search/types.ts new file mode 100644 index 000000000000..3ebc2797947a --- /dev/null +++ b/src/components/Search/types.ts @@ -0,0 +1,17 @@ +/** Model of the selected transaction */ +type SelectedTransactionInfo = { + /** Whether the transaction is selected */ + isSelected: boolean; + + /** If the transaction can be deleted */ + canDelete: boolean; + + /** The action that can be performed for the transaction */ + action: string; +}; + +/** Model of selected results */ +type SelectedTransactions = Record; + +// eslint-disable-next-line import/prefer-default-export +export type {SelectedTransactionInfo, SelectedTransactions}; diff --git a/src/components/SelectionList/BaseListItem.tsx b/src/components/SelectionList/BaseListItem.tsx index c9dc773c8818..99330478c75f 100644 --- a/src/components/SelectionList/BaseListItem.tsx +++ b/src/components/SelectionList/BaseListItem.tsx @@ -82,6 +82,7 @@ function BaseListItem({ onSelectRow(item); }} disabled={isDisabled && !item.isSelected} + interactive={item.isInteractive} accessibilityLabel={item.text ?? ''} role={CONST.ROLE.BUTTON} hoverDimmingValue={1} diff --git a/src/components/SelectionList/BaseSelectionList.tsx b/src/components/SelectionList/BaseSelectionList.tsx index 617c70a1d224..8b6ba790e6b0 100644 --- a/src/components/SelectionList/BaseSelectionList.tsx +++ b/src/components/SelectionList/BaseSelectionList.tsx @@ -24,6 +24,7 @@ import usePrevious from '@hooks/usePrevious'; import useThemeStyles from '@hooks/useThemeStyles'; import getSectionsWithIndexOffset from '@libs/getSectionsWithIndexOffset'; import Log from '@libs/Log'; +import * as SearchUtils from '@libs/SearchUtils'; import variables from '@styles/variables'; import CONST from '@src/CONST'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; @@ -165,7 +166,7 @@ function BaseSelectionList( itemLayouts.push({length: fullItemHeight, offset}); offset += fullItemHeight; - if (item.isSelected) { + if (item.isSelected && !selectedOptions.find((option) => option.keyForList === item.keyForList)) { selectedOptions.push(item); } }); @@ -221,7 +222,7 @@ function BaseSelectionList( return [processedSections, showMoreButton]; // we don't need to add styles here as they change // we don't need to add flattendedSections here as they will change along with sections - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps }, [sections, currentPage]); // Disable `Enter` shortcut if the active element is a button or checkbox @@ -247,7 +248,7 @@ function BaseSelectionList( listRef.current.scrollToLocation({sectionIndex, itemIndex, animated, viewOffset: variables.contentHeaderHeight}); }, - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps [flattenedSections.allOptions], ); @@ -258,7 +259,7 @@ function BaseSelectionList( } setDisabledArrowKeyIndexes(flattenedSections.disabledArrowKeyOptionsIndexes); - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps }, [flattenedSections.disabledArrowKeyOptionsIndexes]); // If `initiallyFocusedOptionKey` is not passed, we fall back to `-1`, to avoid showing the highlight on the first member @@ -277,7 +278,7 @@ function BaseSelectionList( onChangeText?.(''); }, [onChangeText]); - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps const debouncedOnSelectRow = useCallback(lodashDebounce(onSelectRow, 200), [onSelectRow]); /** @@ -336,7 +337,7 @@ function BaseSelectionList( // This debounce happens on the trailing edge because on repeated enter presses, rapid component state update cancels the existing debounce and the redundant // enter presses runs the debounced function again. - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps const debouncedSelectFocusedOption = useCallback(lodashDebounce(selectFocusedOption, 100), [selectFocusedOption]); /** @@ -431,6 +432,13 @@ function BaseSelectionList( // We only create tooltips for the first 10 users or so since some reports have hundreds of users, causing performance to degrade. const showTooltip = shouldShowTooltips && normalizedIndex < 10; + const handleOnCheckboxPress = () => { + if (SearchUtils.isReportListItemType(item)) { + return onCheckboxPress; + } + return onCheckboxPress ? () => onCheckboxPress(item) : undefined; + }; + return ( <> ( showTooltip={showTooltip} canSelectMultiple={canSelectMultiple} onSelectRow={() => selectRow(item)} - onCheckboxPress={onCheckboxPress ? () => onCheckboxPress?.(item) : undefined} + onCheckboxPress={handleOnCheckboxPress()} onDismissError={() => onDismissError?.(item)} shouldPreventDefaultFocusOnSelectRow={shouldPreventDefaultFocusOnSelectRow} // We're already handling the Enter key press in the useKeyboardShortcut hook, so we don't want the list item to submit the form diff --git a/src/components/SelectionList/Search/ActionCell.tsx b/src/components/SelectionList/Search/ActionCell.tsx index 6aabfebf0da9..5af3d84bf32f 100644 --- a/src/components/SelectionList/Search/ActionCell.tsx +++ b/src/components/SelectionList/Search/ActionCell.tsx @@ -14,13 +14,17 @@ type ActionCellProps = { onButtonPress: () => void; action?: string; isLargeScreenWidth?: boolean; + isSelected?: boolean; }; -function ActionCell({onButtonPress, action = CONST.SEARCH.ACTION_TYPES.VIEW, isLargeScreenWidth = true}: ActionCellProps) { +function ActionCell({onButtonPress, action = CONST.SEARCH.ACTION_TYPES.VIEW, isLargeScreenWidth = true, isSelected = false}: ActionCellProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); const theme = useTheme(); const StyleUtils = useStyleUtils(); + if (!isLargeScreenWidth) { + return null; + } if (action === CONST.SEARCH.ACTION_TYPES.PAID || action === CONST.SEARCH.ACTION_TYPES.DONE) { const buttonTextKey = action === CONST.SEARCH.ACTION_TYPES.PAID ? 'iou.settledExpensify' : 'common.done'; @@ -53,6 +57,7 @@ function ActionCell({onButtonPress, action = CONST.SEARCH.ACTION_TYPES.VIEW, isL small pressOnEnter style={[styles.w100]} + innerStyles={isSelected ? styles.buttonDefaultHovered : {}} /> ); } diff --git a/src/components/SelectionList/Search/ReportListItem.tsx b/src/components/SelectionList/Search/ReportListItem.tsx index f9e8e1951d9a..7119cee06cd9 100644 --- a/src/components/SelectionList/Search/ReportListItem.tsx +++ b/src/components/SelectionList/Search/ReportListItem.tsx @@ -1,5 +1,6 @@ import React from 'react'; import {View} from 'react-native'; +import Checkbox from '@components/Checkbox'; import BaseListItem from '@components/SelectionList/BaseListItem'; import type {ListItem, ReportListItemProps, ReportListItemType, TransactionListItemType} from '@components/SelectionList/types'; import Text from '@components/Text'; @@ -54,6 +55,7 @@ function ReportListItem({ showTooltip, isDisabled, canSelectMultiple, + onCheckboxPress, onSelectRow, onDismissError, onFocus, @@ -86,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 @@ -104,6 +106,7 @@ function ReportListItem({ showTooltip={showTooltip} isDisabled={isDisabled} canSelectMultiple={canSelectMultiple} + onCheckboxPress={() => onCheckboxPress?.(transactionItem as unknown as TItem)} onSelectRow={() => openReportInRHP(transactionItem)} onDismissError={onDismissError} onFocus={onFocus} @@ -142,10 +145,20 @@ function ReportListItem({ onButtonPress={handleOnButtonPress} /> )} - + - + {canSelectMultiple && ( + onCheckboxPress?.(item)} + isChecked={item.isSelected} + containerStyle={[StyleUtils.getCheckboxContainerStyle(20), StyleUtils.getMultiselectListStyles(!!item.isSelected, !!item.isDisabled)]} + disabled={!!isDisabled || item.isDisabledCheckbox} + accessibilityLabel={item.text ?? ''} + style={[styles.cursorUnset, StyleUtils.getCheckboxPressableStyle(), item.isDisabledCheckbox && styles.cursorDisabled]} + /> + )} + {reportItem?.reportName} {`${reportItem.transactions.length} ${translate('search.groupedExpenses')}`} @@ -167,6 +180,7 @@ function ReportListItem({ isLargeScreenWidth={isLargeScreenWidth} onButtonPress={handleOnButtonPress} action={reportItem.action} + isSelected={item.isSelected} /> @@ -180,9 +194,13 @@ function ReportListItem({ onButtonPress={() => { openReportInRHP(transaction); }} + onCheckboxPress={() => onCheckboxPress?.(transaction as unknown as TItem)} showItemHeaderOnNarrowLayout={false} containerStyle={styles.mt3} isChildListItem + isDisabled={!!isDisabled} + canSelectMultiple={!!canSelectMultiple} + isButtonSelected={item.isSelected} /> ))} diff --git a/src/components/SelectionList/Search/TransactionListItem.tsx b/src/components/SelectionList/Search/TransactionListItem.tsx index 23ab549dd495..b00ae0703c2e 100644 --- a/src/components/SelectionList/Search/TransactionListItem.tsx +++ b/src/components/SelectionList/Search/TransactionListItem.tsx @@ -12,6 +12,7 @@ function TransactionListItem({ isDisabled, canSelectMultiple, onSelectRow, + onCheckboxPress, onDismissError, onFocus, shouldSyncFocus, @@ -54,6 +55,10 @@ function TransactionListItem({ onButtonPress={() => { onSelectRow(item); }} + onCheckboxPress={() => onCheckboxPress?.(item)} + isDisabled={!!isDisabled} + canSelectMultiple={!!canSelectMultiple} + isButtonSelected={item.isSelected} /> ); diff --git a/src/components/SelectionList/Search/TransactionListItemRow.tsx b/src/components/SelectionList/Search/TransactionListItemRow.tsx index 0adc7ee21fd1..23f9234819c3 100644 --- a/src/components/SelectionList/Search/TransactionListItemRow.tsx +++ b/src/components/SelectionList/Search/TransactionListItemRow.tsx @@ -1,6 +1,7 @@ import React from 'react'; import type {StyleProp, ViewStyle} from 'react-native'; import {View} from 'react-native'; +import Checkbox from '@components/Checkbox'; import Icon from '@components/Icon'; import * as Expensicons from '@components/Icon/Expensicons'; import ReceiptImage from '@components/ReceiptImage'; @@ -13,6 +14,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; import * as CurrencyUtils from '@libs/CurrencyUtils'; import DateUtils from '@libs/DateUtils'; +import StringUtils from '@libs/StringUtils'; import * as TransactionUtils from '@libs/TransactionUtils'; import tryResolveUrlFromApiRoot from '@libs/tryResolveUrlFromApiRoot'; import variables from '@styles/variables'; @@ -43,9 +45,13 @@ type TransactionListItemRowProps = { item: TransactionListItemType; showTooltip: boolean; onButtonPress: () => void; + onCheckboxPress: () => void; showItemHeaderOnNarrowLayout?: boolean; containerStyle?: StyleProp; isChildListItem?: boolean; + isDisabled: boolean; + canSelectMultiple: boolean; + isButtonSelected?: boolean; }; const getTypeIcon = (type?: SearchTransactionType) => { @@ -109,16 +115,20 @@ function MerchantCell({transactionItem, showTooltip, isLargeScreenWidth}: Transa const styles = useThemeStyles(); const {translate} = useLocalize(); const description = TransactionUtils.getDescription(transactionItem); - let merchant = transactionItem.shouldShowMerchant ? transactionItem.formattedMerchant : description; + let merchantOrDescriptionToDisplay = transactionItem.formattedMerchant; + if (!merchantOrDescriptionToDisplay && !isLargeScreenWidth) { + merchantOrDescriptionToDisplay = description; + } + let merchant = transactionItem.shouldShowMerchant ? merchantOrDescriptionToDisplay : description; if (TransactionUtils.hasReceipt(transactionItem) && TransactionUtils.isReceiptBeingScanned(transactionItem) && transactionItem.shouldShowMerchant) { merchant = translate('iou.receiptStatusTitle'); } - + const merchantToDisplay = StringUtils.getFirstLine(merchant); return ( ); @@ -159,18 +169,25 @@ function TypeCell({transactionItem, isLargeScreenWidth}: TransactionCellProps) { function CategoryCell({isLargeScreenWidth, showTooltip, transactionItem}: TransactionCellProps) { const styles = useThemeStyles(); - return isLargeScreenWidth ? ( + return ( - ) : ( - ); } @@ -209,7 +226,18 @@ function TaxCell({transactionItem, showTooltip}: TransactionCellProps) { ); } -function TransactionListItemRow({item, showTooltip, onButtonPress, showItemHeaderOnNarrowLayout = true, containerStyle, isChildListItem = false}: TransactionListItemRowProps) { +function TransactionListItemRow({ + item, + showTooltip, + isDisabled, + canSelectMultiple, + onButtonPress, + onCheckboxPress, + showItemHeaderOnNarrowLayout = true, + containerStyle, + isChildListItem = false, + isButtonSelected = false, +}: TransactionListItemRowProps) { const styles = useThemeStyles(); const {isLargeScreenWidth} = useWindowDimensions(); const StyleUtils = useStyleUtils(); @@ -234,42 +262,39 @@ function TransactionListItemRow({item, showTooltip, onButtonPress, showItemHeade isLargeScreenWidth={false} showTooltip={false} /> - + - - - - + {item.category && ( + + + + )} @@ -280,11 +305,20 @@ function TransactionListItemRow({item, showTooltip, onButtonPress, showItemHeade return ( - + {canSelectMultiple && ( + + )} + @@ -292,14 +326,14 @@ function TransactionListItemRow({item, showTooltip, onButtonPress, showItemHeade @@ -317,7 +351,7 @@ function TransactionListItemRow({item, showTooltip, onButtonPress, showItemHeade {item.shouldShowCategory && ( @@ -326,7 +360,7 @@ function TransactionListItemRow({item, showTooltip, onButtonPress, showItemHeade {item.shouldShowTag && ( @@ -336,7 +370,7 @@ function TransactionListItemRow({item, showTooltip, onButtonPress, showItemHeade @@ -346,14 +380,14 @@ function TransactionListItemRow({item, showTooltip, onButtonPress, showItemHeade @@ -361,6 +395,7 @@ function TransactionListItemRow({item, showTooltip, onButtonPress, showItemHeade diff --git a/src/components/SelectionList/SearchTableHeader.tsx b/src/components/SelectionList/SearchTableHeader.tsx index 6ba753273e8c..95e4b680692b 100644 --- a/src/components/SelectionList/SearchTableHeader.tsx +++ b/src/components/SelectionList/SearchTableHeader.tsx @@ -53,17 +53,17 @@ const SearchColumns: SearchColumnConfig[] = [ { columnName: CONST.SEARCH.TABLE_COLUMNS.CATEGORY, translationKey: 'common.category', - shouldShow: (data, metadata) => metadata?.columnsToShow.shouldShowCategoryColumn ?? false, + shouldShow: (data, metadata) => metadata?.columnsToShow?.shouldShowCategoryColumn ?? false, }, { columnName: CONST.SEARCH.TABLE_COLUMNS.TAG, translationKey: 'common.tag', - shouldShow: (data, metadata) => metadata?.columnsToShow.shouldShowTagColumn ?? false, + shouldShow: (data, metadata) => metadata?.columnsToShow?.shouldShowTagColumn ?? false, }, { columnName: CONST.SEARCH.TABLE_COLUMNS.TAX_AMOUNT, translationKey: 'common.tax', - shouldShow: (data, metadata) => metadata?.columnsToShow.shouldShowTaxColumn ?? false, + shouldShow: (data, metadata) => metadata?.columnsToShow?.shouldShowTaxColumn ?? false, isColumnSortable: false, }, { @@ -107,7 +107,7 @@ function SearchTableHeader({data, metadata, sortBy, sortOrder, isSortingAllowed, } return ( - + {SearchColumns.map(({columnName, translationKey, shouldShow, isColumnSortable}) => { if (!shouldShow(data, metadata)) { 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 && ( ; + + /** A function to run when the X button next to the error is clicked */ + onClose?: () => void; }; function SelectionScreen({ @@ -81,8 +99,13 @@ function SelectionScreen({ featureName, shouldBeBlocked, connectionName, + pendingAction, + errors, + errorRowStyles, + onClose, }: SelectionScreenProps) { const {translate} = useLocalize(); + const styles = useThemeStyles(); const policy = PolicyUtils.getPolicy(policyID); const isConnectionEmpty = isEmpty(policy?.connections?.[connectionName]); @@ -95,24 +118,33 @@ function SelectionScreen({ shouldBeBlocked={isConnectionEmpty || shouldBeBlocked} > - + {headerContent} + + + ); diff --git a/src/components/SettlementButton.tsx b/src/components/SettlementButton.tsx index d3916220ca88..7c3c021a08eb 100644 --- a/src/components/SettlementButton.tsx +++ b/src/components/SettlementButton.tsx @@ -4,8 +4,10 @@ import type {OnyxEntry} from 'react-native-onyx'; import {useOnyx, withOnyx} from 'react-native-onyx'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; +import Navigation from '@libs/Navigation/Navigation'; import * as ReportUtils from '@libs/ReportUtils'; import playSound, {SOUNDS} from '@libs/Sound'; +import * as SubscriptionUtils from '@libs/SubscriptionUtils'; import * as BankAccounts from '@userActions/BankAccounts'; import * as IOU from '@userActions/IOU'; import CONST from '@src/CONST'; @@ -223,10 +225,15 @@ 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 + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps }, [currency, formattedAmount, iouReport, policyID, translate, shouldHidePaymentOptions, shouldShowApproveButton, shouldDisableApproveButton]); const selectPaymentType = (event: KYCFlowEvent, iouPaymentType: PaymentMethodType, triggerKYCFlow: TriggerKYCFlow) => { + if (policy && SubscriptionUtils.shouldRestrictUserBillableActions(policy.id)) { + Navigation.navigate(ROUTES.RESTRICTED_ACTION.getRoute(policy.id)); + return; + } + if (iouPaymentType === CONST.IOU.PAYMENT_TYPE.EXPENSIFY || iouPaymentType === CONST.IOU.PAYMENT_TYPE.VBBA) { triggerKYCFlow(event, iouPaymentType); BankAccounts.setPersonalBankAccountContinueKYCOnSuccess(ROUTES.ENABLE_PAYMENTS); diff --git a/src/components/SingleChoiceQuestion.tsx b/src/components/SingleChoiceQuestion.tsx index c2dc72438e43..e52007850475 100644 --- a/src/components/SingleChoiceQuestion.tsx +++ b/src/components/SingleChoiceQuestion.tsx @@ -22,7 +22,7 @@ function SingleChoiceQuestion({prompt, errorText, possibleAnswers, currentQuesti <> {prompt} diff --git a/src/components/SpacerView.tsx b/src/components/SpacerView.tsx index 23c109f7d589..bb762da1226b 100644 --- a/src/components/SpacerView.tsx +++ b/src/components/SpacerView.tsx @@ -35,10 +35,11 @@ function SpacerView({shouldShow, style}: SpacerViewProps) { marginVertical: shouldShow ? CONST.HORIZONTAL_SPACER.DEFAULT_MARGIN_VERTICAL : CONST.HORIZONTAL_SPACER.HIDDEN_MARGIN_VERTICAL, borderBottomWidth: shouldShow ? CONST.HORIZONTAL_SPACER.DEFAULT_BORDER_BOTTOM_WIDTH : CONST.HORIZONTAL_SPACER.HIDDEN_BORDER_BOTTOM_WIDTH, }; + // eslint-disable-next-line react-compiler/react-compiler marginVertical.value = values.marginVertical; borderBottomWidth.value = values.borderBottomWidth; - // eslint-disable-next-line react-hooks/exhaustive-deps -- we only need to trigger when shouldShow prop is changed + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps -- we only need to trigger when shouldShow prop is changed }, [shouldShow, prevShouldShow]); return ; diff --git a/src/components/SplashScreenHider/index.native.tsx b/src/components/SplashScreenHider/index.native.tsx index e31f391f7376..7c579519c926 100644 --- a/src/components/SplashScreenHider/index.native.tsx +++ b/src/components/SplashScreenHider/index.native.tsx @@ -34,6 +34,7 @@ function SplashScreenHider({onHide = () => {}}: SplashScreenHiderProps): SplashS hideHasBeenCalled.current = true; BootSplash.hide().then(() => { + // eslint-disable-next-line react-compiler/react-compiler scale.value = withTiming(0, { duration: 200, easing: Easing.back(2), diff --git a/src/components/StateSelector.tsx b/src/components/StateSelector.tsx index 2481c29d8123..7e598a929ae3 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(); @@ -71,7 +71,7 @@ function StateSelector( // This helps prevent issues where the component might not update correctly if the state is controlled by both the parent and the URL. Navigation.setParams({state: undefined}); - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps }, [stateFromUrl, onBlur, isFocused]); const title = stateCode && Object.keys(COMMON_CONST.STATES).includes(stateCode) ? translate(`allStates.${stateCode}.stateName`) : ''; @@ -89,7 +89,7 @@ function StateSelector( brickRoadIndicator={errorText ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} errorText={errorText} onPress={() => { - const activeRoute = Navigation.getActiveRoute(); + const activeRoute = Navigation.getActiveRouteWithoutParams(); didOpenStateSelector.current = true; Navigation.navigate(stateSelectorRoute.getRoute(stateCode, activeRoute, label)); }} 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/TextInput/BaseTextInput/index.native.tsx b/src/components/TextInput/BaseTextInput/index.native.tsx index 8f685fb668e7..6fe5c473797e 100644 --- a/src/components/TextInput/BaseTextInput/index.native.tsx +++ b/src/components/TextInput/BaseTextInput/index.native.tsx @@ -108,7 +108,7 @@ function BaseTextInput( } input.current.focus(); // We only want this to run on mount - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps }, []); const animateLabel = useCallback( diff --git a/src/components/TextInput/BaseTextInput/index.tsx b/src/components/TextInput/BaseTextInput/index.tsx index 3a1032ff7a43..6da87872ee96 100644 --- a/src/components/TextInput/BaseTextInput/index.tsx +++ b/src/components/TextInput/BaseTextInput/index.tsx @@ -108,7 +108,7 @@ function BaseTextInput( } input.current.focus(); // We only want this to run on mount - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps }, []); const animateLabel = useCallback( diff --git a/src/components/TextInput/TextInputLabel/index.tsx b/src/components/TextInput/TextInputLabel/index.tsx index e1083b88414b..3136a43e8669 100644 --- a/src/components/TextInput/TextInputLabel/index.tsx +++ b/src/components/TextInput/TextInputLabel/index.tsx @@ -16,7 +16,7 @@ function TextInputLabel({for: inputId = '', label, labelTranslateY, labelScale}: return; } labelRef.current.setAttribute('for', inputId); - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps }, []); return ( diff --git a/src/components/TextInput/index.tsx b/src/components/TextInput/index.tsx index a9dda9e956cf..4070714c798e 100644 --- a/src/components/TextInput/index.tsx +++ b/src/components/TextInput/index.tsx @@ -40,7 +40,7 @@ function TextInput(props: BaseTextInputProps, ref: ForwardedRef(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/TimePicker/TimePicker.tsx b/src/components/TimePicker/TimePicker.tsx index 8905abd370fe..cc1b6a161404 100644 --- a/src/components/TimePicker/TimePicker.tsx +++ b/src/components/TimePicker/TimePicker.tsx @@ -355,7 +355,7 @@ function TimePicker({defaultValue = '', onSubmit, onInputChange = () => {}}: Tim handleMinutesChange(insertAtPosition(minutes, trimmedKey, selectionMinute.start, selectionMinute.end)); } }, - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps [minutes, hours, selectionMinute, selectionHour], ); @@ -381,7 +381,7 @@ function TimePicker({defaultValue = '', onSubmit, onInputChange = () => {}}: Tim focusHourInputOnLastCharacter(); } }, - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps [selectionHour, selectionMinute], ); const arrowRightCallback = useCallback( @@ -394,7 +394,7 @@ function TimePicker({defaultValue = '', onSubmit, onInputChange = () => {}}: Tim focusMinuteInputOnFirstCharacter(); } }, - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps [selectionHour, selectionMinute], ); @@ -409,7 +409,7 @@ function TimePicker({defaultValue = '', onSubmit, onInputChange = () => {}}: Tim e.preventDefault(); focusHourInputOnLastCharacter(); }, - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps [selectionMinute.start, selectionMinute.end, focusHourInputOnLastCharacter], ); @@ -430,7 +430,7 @@ function TimePicker({defaultValue = '', onSubmit, onInputChange = () => {}}: Tim useEffect(() => { onInputChange(`${hours}:${minutes} ${amPmValue}`); - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps }, [hours, minutes, amPmValue]); const handleSubmit = () => { @@ -463,6 +463,7 @@ function TimePicker({defaultValue = '', onSubmit, onInputChange = () => {}}: Tim // eslint-disable-next-line no-param-reassign ref.current = {hourRef: textInputRef as TextInput | null, minuteRef: minuteInputRef.current}; } + // eslint-disable-next-line react-compiler/react-compiler hourInputRef.current = textInputRef as TextInput | null; }} onSelectionChange={(e) => { diff --git a/src/components/Tooltip/EducationalTooltip/BaseEducationalTooltip.tsx b/src/components/Tooltip/EducationalTooltip/BaseEducationalTooltip.tsx index 1398d74bbd67..50a1c9024293 100644 --- a/src/components/Tooltip/EducationalTooltip/BaseEducationalTooltip.tsx +++ b/src/components/Tooltip/EducationalTooltip/BaseEducationalTooltip.tsx @@ -41,6 +41,7 @@ function BaseEducationalTooltip({children, ...props}: TooltipProps) { {...props} > {({showTooltip, hideTooltip, updateTargetBounds}) => { + // eslint-disable-next-line react-compiler/react-compiler hideTooltipRef.current = hideTooltip; return React.cloneElement(children as React.ReactElement, { onLayout: (e: LayoutEvent) => { 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/components/VideoPlayer/BaseVideoPlayer.tsx b/src/components/VideoPlayer/BaseVideoPlayer.tsx index 8230f9132d00..c6a244be3fcb 100644 --- a/src/components/VideoPlayer/BaseVideoPlayer.tsx +++ b/src/components/VideoPlayer/BaseVideoPlayer.tsx @@ -124,6 +124,7 @@ function BaseVideoPlayer({ if (videoResumeTryNumber.current === 1) { playVideo(); } + // eslint-disable-next-line react-compiler/react-compiler videoResumeTryNumber.current -= 1; }, [playVideo, videoResumeTryNumber], @@ -160,7 +161,7 @@ function BaseVideoPlayer({ videoStateRef.current = status; onPlaybackStatusUpdate?.(status); }, - // eslint-disable-next-line react-hooks/exhaustive-deps -- we don't want to trigger this when isPlaying changes because isPlaying is only used inside shouldReplayVideo + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps -- we don't want to trigger this when isPlaying changes because isPlaying is only used inside shouldReplayVideo [onPlaybackStatusUpdate, preventPausingWhenExitingFullscreen, videoDuration], ); diff --git a/src/components/VideoPlayer/VideoPlayerControls/ProgressBar/index.tsx b/src/components/VideoPlayer/VideoPlayerControls/ProgressBar/index.tsx index c9cf2c25d7ad..5d1ea0d85d0b 100644 --- a/src/components/VideoPlayer/VideoPlayerControls/ProgressBar/index.tsx +++ b/src/components/VideoPlayer/VideoPlayerControls/ProgressBar/index.tsx @@ -30,6 +30,7 @@ function ProgressBar({duration, position, seekPosition}: ProgressBarProps) { const wasVideoPlayingOnCheck = useSharedValue(false); const onCheckVideoPlaying = (isPlaying: boolean) => { + // eslint-disable-next-line react-compiler/react-compiler wasVideoPlayingOnCheck.value = isPlaying; }; diff --git a/src/components/VideoPlayer/VideoPlayerControls/VolumeButton/index.tsx b/src/components/VideoPlayer/VideoPlayerControls/VolumeButton/index.tsx index 011391ed4c71..abe1748c18b9 100644 --- a/src/components/VideoPlayer/VideoPlayerControls/VolumeButton/index.tsx +++ b/src/components/VideoPlayer/VideoPlayerControls/VolumeButton/index.tsx @@ -45,6 +45,7 @@ function VolumeButton({style, small = false}: VolumeButtonProps) { const changeVolumeOnPan = useCallback( (event: GestureStateChangeEvent | GestureUpdateEvent) => { const val = NumberUtils.roundToTwoDecimalPlaces(1 - event.y / sliderHeight); + // eslint-disable-next-line react-compiler/react-compiler volume.value = NumberUtils.clamp(val, 0, 1); }, [sliderHeight, volume], diff --git a/src/components/VideoPlayer/VideoPlayerControls/index.tsx b/src/components/VideoPlayer/VideoPlayerControls/index.tsx index f5461159aa8e..04cb69a96f8b 100644 --- a/src/components/VideoPlayer/VideoPlayerControls/index.tsx +++ b/src/components/VideoPlayer/VideoPlayerControls/index.tsx @@ -72,6 +72,7 @@ function VideoPlayerControls({ }; const enterFullScreenMode = useCallback(() => { + // eslint-disable-next-line react-compiler/react-compiler isFullScreenRef.current = true; updateCurrentlyPlayingURL(url); videoPlayerRef.current?.presentFullscreenPlayer(); diff --git a/src/components/VideoPlayerContexts/VideoPopoverMenuContext.tsx b/src/components/VideoPlayerContexts/VideoPopoverMenuContext.tsx index 0958ec148c3d..bfb7e3739d17 100644 --- a/src/components/VideoPlayerContexts/VideoPopoverMenuContext.tsx +++ b/src/components/VideoPlayerContexts/VideoPopoverMenuContext.tsx @@ -62,6 +62,7 @@ function VideoPopoverMenuContextProvider({children}: ChildrenProps) { updatePlaybackSpeed(speed); }, shouldPutLeftPaddingWhenNoIcon: true, + isSelected: currentPlaybackSpeed === speed, })), }); return items; diff --git a/src/components/VideoPlayerContexts/VolumeContext.tsx b/src/components/VideoPlayerContexts/VolumeContext.tsx index d67b989e5887..f22b524848de 100644 --- a/src/components/VideoPlayerContexts/VolumeContext.tsx +++ b/src/components/VideoPlayerContexts/VolumeContext.tsx @@ -16,6 +16,7 @@ function VolumeContextProvider({children}: ChildrenProps) { return; } currentVideoPlayerRef.current.setStatusAsync({volume: newVolume, isMuted: newVolume === 0}); + // eslint-disable-next-line react-compiler/react-compiler volume.value = newVolume; }, [currentVideoPlayerRef, volume], diff --git a/src/components/withNavigationTransitionEnd.tsx b/src/components/withNavigationTransitionEnd.tsx index 417d8828c1e4..83f14a1d58ef 100644 --- a/src/components/withNavigationTransitionEnd.tsx +++ b/src/components/withNavigationTransitionEnd.tsx @@ -18,7 +18,7 @@ export default function (WrappedComponent: ComponentType { diff --git a/src/hooks/useCopySelectionHelper.ts b/src/hooks/useCopySelectionHelper.ts index ed379bfcf2e6..9bcb9b8b0139 100644 --- a/src/hooks/useCopySelectionHelper.ts +++ b/src/hooks/useCopySelectionHelper.ts @@ -1,7 +1,7 @@ import {useEffect} from 'react'; import Clipboard from '@libs/Clipboard'; import KeyboardShortcut from '@libs/KeyboardShortcut'; -import {parseHtmlToMarkdown, parseHtmlToText} from '@libs/OnyxAwareParser'; +import Parser from '@libs/Parser'; import SelectionScraper from '@libs/SelectionScraper'; import CONST from '@src/CONST'; @@ -11,10 +11,10 @@ function copySelectionToClipboard() { return; } if (!Clipboard.canSetHtml()) { - Clipboard.setString(parseHtmlToMarkdown(selection)); + Clipboard.setString(Parser.htmlToMarkdown(selection)); return; } - Clipboard.setHtml(selection, parseHtmlToText(selection)); + Clipboard.setHtml(selection, Parser.htmlToText(selection)); } export default function useCopySelectionHelper() { 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/useHtmlPaste/index.ts b/src/hooks/useHtmlPaste/index.ts index 4705a170c3bd..5888f96d1c15 100644 --- a/src/hooks/useHtmlPaste/index.ts +++ b/src/hooks/useHtmlPaste/index.ts @@ -1,6 +1,6 @@ import {useNavigation} from '@react-navigation/native'; import {useCallback, useEffect} from 'react'; -import {parseHtmlToMarkdown} from '@libs/OnyxAwareParser'; +import Parser from '@libs/Parser'; import type UseHtmlPaste from './types'; const insertByCommand = (text: string) => { @@ -61,7 +61,7 @@ const useHtmlPaste: UseHtmlPaste = (textInputRef, preHtmlPasteCallback, removeLi // eslint-disable-next-line no-empty } catch (e) {} // We only need to set the callback once. - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps }, []); /** @@ -71,7 +71,7 @@ const useHtmlPaste: UseHtmlPaste = (textInputRef, preHtmlPasteCallback, removeLi */ const handlePastedHTML = useCallback( (html: string) => { - paste(parseHtmlToMarkdown(html)); + paste(Parser.htmlToMarkdown(html)); }, [paste], ); @@ -131,7 +131,7 @@ const useHtmlPaste: UseHtmlPaste = (textInputRef, preHtmlPasteCallback, removeLi } handlePastePlainText(event); }, - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps [handlePastedHTML, handlePastePlainText, preHtmlPasteCallback], ); @@ -155,7 +155,7 @@ const useHtmlPaste: UseHtmlPaste = (textInputRef, preHtmlPasteCallback, removeLi } document.removeEventListener('paste', handlePaste); }; - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps }, []); }; diff --git a/src/hooks/useKeyboardShortcut.ts b/src/hooks/useKeyboardShortcut.ts index 1c5bbc426ef2..90f43b4520d1 100644 --- a/src/hooks/useKeyboardShortcut.ts +++ b/src/hooks/useKeyboardShortcut.ts @@ -63,6 +63,6 @@ export default function useKeyboardShortcut(shortcut: Shortcut, callback: (e?: G return () => { unsubscribe(); }; - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps }, [isActive, callback, captureOnInputs, excludedNodes, priority, shortcut.descriptionKey, shortcut.modifiers.join(), shortcut.shortcutKey, shouldBubble, shouldPreventDefault]); } diff --git a/src/hooks/useMarkdownStyle.ts b/src/hooks/useMarkdownStyle.ts index 9b5198cd040d..c7e9bf2c0218 100644 --- a/src/hooks/useMarkdownStyle.ts +++ b/src/hooks/useMarkdownStyle.ts @@ -9,7 +9,8 @@ const defaultEmptyArray: Array = []; function useMarkdownStyle(message: string | null = null, excludeStyles: Array = defaultEmptyArray): MarkdownStyle { const theme = useTheme(); - const emojiFontSize = containsOnlyEmojis(message ?? '') ? variables.fontSizeOnlyEmojis : variables.fontSizeNormal; + const hasMessageOnlyEmojis = message != null && message.length > 0 && containsOnlyEmojis(message); + const emojiFontSize = hasMessageOnlyEmojis ? variables.fontSizeOnlyEmojis : variables.fontSizeNormal; // this map is used to reset the styles that are not needed - passing undefined value can break the native side const nonStylingDefaultValues: Record = useMemo( diff --git a/src/hooks/useReportIDs.tsx b/src/hooks/useReportIDs.tsx index a90793857293..38622e17d2be 100644 --- a/src/hooks/useReportIDs.tsx +++ b/src/hooks/useReportIDs.tsx @@ -3,7 +3,6 @@ import type {OnyxEntry} from 'react-native-onyx'; import {useOnyx} from 'react-native-onyx'; import {getPolicyEmployeeListByIdWithoutCurrentUser} from '@libs/PolicyUtils'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; -import * as ReportUtils from '@libs/ReportUtils'; import SidebarUtils from '@libs/SidebarUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -13,7 +12,6 @@ import useActiveWorkspace from './useActiveWorkspace'; import useCurrentReportID from './useCurrentReportID'; import useCurrentUserPersonalDetails from './useCurrentUserPersonalDetails'; -type ChatReportSelector = OnyxTypes.Report & {isUnreadWithMention: boolean}; type PolicySelector = Pick; type ReportActionsSelector = Array>; @@ -25,56 +23,19 @@ type ReportIDsContextProviderProps = { type ReportIDsContextValue = { orderedReportIDs: string[]; currentReportID: string; + policyMemberAccountIDs: number[]; }; const ReportIDsContext = createContext({ orderedReportIDs: [], currentReportID: '', + policyMemberAccountIDs: [], }); /** * This function (and the few below it), narrow down the data from Onyx to just the properties that we want to trigger a re-render of the component. This helps minimize re-rendering * and makes the entire component more performant because it's not re-rendering when a bunch of properties change which aren't ever used in the UI. */ -const chatReportSelector = (report: OnyxEntry): ChatReportSelector => - (report && { - reportID: report.reportID, - participants: report.participants, - isPinned: report.isPinned, - isHidden: report.isHidden, - notificationPreference: report.notificationPreference, - errorFields: { - addWorkspaceRoom: report.errorFields?.addWorkspaceRoom, - }, - lastMessageText: report.lastMessageText, - lastVisibleActionCreated: report.lastVisibleActionCreated, - iouReportID: report.iouReportID, - total: report.total, - nonReimbursableTotal: report.nonReimbursableTotal, - hasOutstandingChildRequest: report.hasOutstandingChildRequest, - isWaitingOnBankAccount: report.isWaitingOnBankAccount, - statusNum: report.statusNum, - stateNum: report.stateNum, - chatType: report.chatType, - type: report.type, - policyID: report.policyID, - visibility: report.visibility, - lastReadTime: report.lastReadTime, - // Needed for name sorting: - reportName: report.reportName, - policyName: report.policyName, - oldPolicyName: report.oldPolicyName, - // Other less obvious properites considered for sorting: - ownerAccountID: report.ownerAccountID, - currency: report.currency, - managerID: report.managerID, - // Other important less obivous properties for filtering: - parentReportActionID: report.parentReportActionID, - parentReportID: report.parentReportID, - isDeletedParentAction: report.isDeletedParentAction, - isUnreadWithMention: ReportUtils.isUnreadWithMention(report), - }) as ChatReportSelector; - const reportActionsSelector = (reportActions: OnyxEntry): ReportActionsSelector => (reportActions && Object.values(reportActions) @@ -118,7 +79,7 @@ function ReportIDsContextProvider({ currentReportIDForTests, }: ReportIDsContextProviderProps) { const [priorityMode] = useOnyx(ONYXKEYS.NVP_PRIORITY_MODE, {initialValue: CONST.PRIORITY_MODE.DEFAULT}); - const [chatReports] = useOnyx(ONYXKEYS.COLLECTION.REPORT, {selector: chatReportSelector}); + const [chatReports] = useOnyx(ONYXKEYS.COLLECTION.REPORT); const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: policySelector}); const [allReportActions] = useOnyx(ONYXKEYS.COLLECTION.REPORT_ACTIONS, {selector: reportActionsSelector}); const [transactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS); @@ -130,7 +91,7 @@ function ReportIDsContextProvider({ const derivedCurrentReportID = currentReportIDForTests ?? currentReportIDValue?.currentReportID; const {activeWorkspaceID} = useActiveWorkspace(); - const policyMemberAccountIDs = getPolicyEmployeeListByIdWithoutCurrentUser(policies, activeWorkspaceID, accountID); + const policyMemberAccountIDs = useMemo(() => getPolicyEmployeeListByIdWithoutCurrentUser(policies, activeWorkspaceID, accountID), [policies, activeWorkspaceID, accountID]); const getOrderedReportIDs = useCallback( (currentReportID?: string) => @@ -146,7 +107,7 @@ function ReportIDsContextProvider({ policyMemberAccountIDs, ), // we need reports draft in deps array for reloading of list when reportsDrafts will change - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps [chatReports, betas, policies, priorityMode, allReportActions, transactionViolations, activeWorkspaceID, policyMemberAccountIDs, reportsDrafts], ); @@ -157,15 +118,16 @@ function ReportIDsContextProvider({ // we first generate the list as if there was no current report, then we check if // the current report is missing from the list, which should very rarely happen. In this // case we re-generate the list a 2nd time with the current report included. - if (derivedCurrentReportID && !orderedReportIDs.includes(derivedCurrentReportID)) { - return {orderedReportIDs: getOrderedReportIDs(derivedCurrentReportID), currentReportID: derivedCurrentReportID ?? '-1'}; + if (derivedCurrentReportID && derivedCurrentReportID !== '-1' && orderedReportIDs.indexOf(derivedCurrentReportID) === -1) { + return {orderedReportIDs: getOrderedReportIDs(derivedCurrentReportID), currentReportID: derivedCurrentReportID ?? '-1', policyMemberAccountIDs}; } return { orderedReportIDs, currentReportID: derivedCurrentReportID ?? '-1', + policyMemberAccountIDs, }; - }, [getOrderedReportIDs, orderedReportIDs, derivedCurrentReportID]); + }, [getOrderedReportIDs, orderedReportIDs, derivedCurrentReportID, policyMemberAccountIDs]); return {children}; } @@ -175,4 +137,4 @@ function useReportIDs() { } export {ReportIDsContext, ReportIDsContextProvider, policySelector, useReportIDs}; -export type {ChatReportSelector, PolicySelector, ReportActionsSelector}; +export type {PolicySelector, ReportActionsSelector}; diff --git a/src/hooks/useReviewDuplicatesNavigation.tsx b/src/hooks/useReviewDuplicatesNavigation.tsx new file mode 100644 index 000000000000..f92abe63c852 --- /dev/null +++ b/src/hooks/useReviewDuplicatesNavigation.tsx @@ -0,0 +1,52 @@ +import {useEffect, useMemo, useState} from 'react'; +import Navigation from '@libs/Navigation/Navigation'; +import CONST from '@src/CONST'; +import ROUTES from '@src/ROUTES'; + +type StepName = 'description' | 'merchant' | 'category' | 'billable' | 'tag' | 'taxCode' | 'reimbursable'; + +function useReviewDuplicatesNavigation(stepNames: string[], currentScreenName: StepName, threadReportID: string) { + const [nextScreen, setNextScreen] = useState(currentScreenName); + const [currentScreenIndex, setCurrentScreenIndex] = useState(0); + const intersection = useMemo(() => CONST.REVIEW_DUPLICATES_ORDER.filter((element) => stepNames.includes(element)), [stepNames]); + + useEffect(() => { + const currentIndex = intersection.indexOf(currentScreenName); + const nextScreenIndex = currentIndex + 1; + setCurrentScreenIndex(currentIndex); + setNextScreen(intersection[nextScreenIndex] ?? ''); + }, [currentScreenName, intersection]); + + const navigateToNextScreen = () => { + switch (nextScreen) { + case 'merchant': + Navigation.navigate(ROUTES.TRANSACTION_DUPLICATE_REVIEW_MERCHANT_PAGE.getRoute(threadReportID)); + break; + case 'category': + Navigation.navigate(ROUTES.TRANSACTION_DUPLICATE_REVIEW_CATEGORY_PAGE.getRoute(threadReportID)); + break; + case 'tag': + Navigation.navigate(ROUTES.TRANSACTION_DUPLICATE_REVIEW_TAG_PAGE.getRoute(threadReportID)); + break; + case 'description': + Navigation.navigate(ROUTES.TRANSACTION_DUPLICATE_REVIEW_DESCRIPTION_PAGE.getRoute(threadReportID)); + break; + case 'taxCode': + Navigation.navigate(ROUTES.TRANSACTION_DUPLICATE_REVIEW_TAX_CODE_PAGE.getRoute(threadReportID)); + break; + case 'reimbursable': + Navigation.navigate(ROUTES.TRANSACTION_DUPLICATE_REVIEW_REIMBURSABLE_PAGE.getRoute(threadReportID)); + break; + case 'billable': + Navigation.navigate(ROUTES.TRANSACTION_DUPLICATE_REVIEW_BILLABLE_PAGE.getRoute(threadReportID)); + break; + default: + // Navigation to confirm screen will be done in seperate PR + break; + } + }; + + return {navigateToNextScreen, currentScreenIndex}; +} + +export default useReviewDuplicatesNavigation; diff --git a/src/hooks/useSubscriptionPossibleCostSavings.ts b/src/hooks/useSubscriptionPossibleCostSavings.ts index ef92009549fe..059445ce002d 100644 --- a/src/hooks/useSubscriptionPossibleCostSavings.ts +++ b/src/hooks/useSubscriptionPossibleCostSavings.ts @@ -13,10 +13,6 @@ const POSSIBLE_COST_SAVINGS = { [CONST.POLICY.TYPE.TEAM]: 1400, [CONST.POLICY.TYPE.CORPORATE]: 3000, }, - [CONST.PAYMENT_CARD_CURRENCY.GBP]: { - [CONST.POLICY.TYPE.TEAM]: 800, - [CONST.POLICY.TYPE.CORPORATE]: 1400, - }, [CONST.PAYMENT_CARD_CURRENCY.NZD]: { [CONST.POLICY.TYPE.TEAM]: 1600, [CONST.POLICY.TYPE.CORPORATE]: 3200, diff --git a/src/hooks/useSubscriptionPrice.ts b/src/hooks/useSubscriptionPrice.ts index 0b71fe62c7c8..9279ff94757d 100644 --- a/src/hooks/useSubscriptionPrice.ts +++ b/src/hooks/useSubscriptionPrice.ts @@ -25,16 +25,6 @@ const SUBSCRIPTION_PRICES = { [CONST.SUBSCRIPTION.TYPE.PAYPERUSE]: 1400, }, }, - [CONST.PAYMENT_CARD_CURRENCY.GBP]: { - [CONST.POLICY.TYPE.CORPORATE]: { - [CONST.SUBSCRIPTION.TYPE.ANNUAL]: 700, - [CONST.SUBSCRIPTION.TYPE.PAYPERUSE]: 1400, - }, - [CONST.POLICY.TYPE.TEAM]: { - [CONST.SUBSCRIPTION.TYPE.ANNUAL]: 400, - [CONST.SUBSCRIPTION.TYPE.PAYPERUSE]: 800, - }, - }, [CONST.PAYMENT_CARD_CURRENCY.NZD]: { [CONST.POLICY.TYPE.CORPORATE]: { [CONST.SUBSCRIPTION.TYPE.ANNUAL]: 1600, diff --git a/src/hooks/useSyncFocus/index.ts b/src/hooks/useSyncFocus/index.ts index f8b3349bc223..6211b9988a82 100644 --- a/src/hooks/useSyncFocus/index.ts +++ b/src/hooks/useSyncFocus/index.ts @@ -21,7 +21,7 @@ const useSyncFocus = (ref: RefObject, isFocused: boolean, shouldSyncFocus } ref.current?.focus(); - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps }, [didScreenTransitionEnd, isFocused, ref]); }; diff --git a/src/hooks/useTabNavigatorFocus/index.ts b/src/hooks/useTabNavigatorFocus/index.ts index f160f4670b26..f85b630a0834 100644 --- a/src/hooks/useTabNavigatorFocus/index.ts +++ b/src/hooks/useTabNavigatorFocus/index.ts @@ -40,7 +40,7 @@ function useTabNavigatorFocus({tabIndex}: UseTabNavigatorFocusParams): boolean { // Retrieve the animation value from the tab navigator, which ranges from 0 to the total number of pages displayed. // Even a minimal scroll towards the camera page (e.g., a value of 0.001 at start) should activate the camera for immediate responsiveness. // STOP!!!!!!! This is not a pattern to be followed! We are conditionally rendering this hook becase when used in the edit flow we'll never be inside a tab navigator. - // eslint-disable-next-line react-hooks/rules-of-hooks + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/rules-of-hooks tabPositionAnimation = useTabAnimation(); } catch (error) { tabPositionAnimation = null; diff --git a/src/languages/en.ts b/src/languages/en.ts index eaf22051dd12..d3cba1e83436 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, @@ -33,7 +35,6 @@ import type { GoBackMessageParams, GoToRoomParams, InstantSummaryParams, - IntegrationsMessageParams, LocalTimeParams, LoggedInAsParams, LogSizeParams, @@ -353,9 +354,13 @@ export default { shared: 'Shared', drafts: 'Drafts', finished: 'Finished', + upgrade: 'Upgrade', companyID: 'Company ID', userID: 'User ID', disable: 'Disable', + initialValue: 'Initial value', + currentDate: 'Current date', + value: 'Value', }, location: { useCurrent: 'Use current location', @@ -789,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', @@ -805,6 +814,11 @@ export default { removed: 'removed', transactionPending: 'Transaction pending.', chooseARate: ({unit}: ReimbursementRateParams) => `Select a workspace reimbursement rate per ${unit}`, + unapprove: 'Unapprove', + unapproveReport: 'Unapprove report', + headsUp: 'Heads up!', + unapproveWithIntegrationWarning: (accountingIntegration: string) => + `This report has already been exported to ${accountingIntegration}. Changes to this report in Expensify may lead to data discrepancies and Expensify Card reconciliation issues. Are you sure you want to unapprove this report?`, }, notificationPreferencesPage: { header: 'Notification preferences', @@ -923,7 +937,7 @@ export default { timezonePage: { timezone: 'Timezone', isShownOnProfile: 'Your timezone is shown on your profile.', - getLocationAutomatically: 'Automatically determine your location.', + getLocationAutomatically: 'Automatically determine your location', }, updateRequiredView: { updateRequired: 'Update required', @@ -973,6 +987,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', @@ -997,7 +1013,7 @@ export default { }, returnToClassic: 'Switch to Expensify Classic', help: 'Help', - accountSettings: 'Account Settings', + accountSettings: 'Account settings', account: 'Account', general: 'General', }, @@ -1463,6 +1479,7 @@ export default { error: { containsReservedWord: 'Name cannot contain the words Expensify or Concierge.', hasInvalidCharacter: 'Name cannot contain a comma or semicolon.', + requiredFirstName: 'First name cannot be empty.', }, }, privatePersonalDetails: { @@ -1976,6 +1993,7 @@ export default { workspace: { common: { card: 'Cards', + expensifyCard: 'Expensify Card', workflows: 'Workflows', workspace: 'Workspace', edit: 'Edit workspace', @@ -1986,7 +2004,7 @@ export default { reimburse: 'Reimbursements', categories: 'Categories', tags: 'Tags', - reportFields: 'Report Fields', + reportFields: 'Report fields', taxes: 'Taxes', bills: 'Bills', invoices: 'Invoices', @@ -2016,7 +2034,6 @@ export default { moreFeatures: 'More features', requested: 'Requested', distanceRates: 'Distance rates', - expensifyCard: 'Expensify Card', welcomeNote: ({workspaceName}: WelcomeNoteParams) => `You have been invited to ${workspaceName || 'a workspace'}! Download the Expensify mobile app at use.expensify.com/download to start tracking your expenses.`, subscription: 'Subscription', @@ -2089,14 +2106,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 +2175,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', @@ -2202,6 +2213,63 @@ export default { noAccountsFound: 'No accounts found', noAccountsFoundDescription: 'Add the account in Xero and sync the connection again.', }, + sageIntacct: { + preferredExporter: 'Preferred exporter', + notConfigured: 'Not configured', + exportDate: { + label: 'Export date', + description: 'Use this date when exporting reports to Sage Intacct.', + values: { + [CONST.SAGE_INTACCT_EXPORT_DATE.LAST_EXPENSE]: { + label: 'Date of last expense', + description: 'Date of the most recent expense on the report.', + }, + [CONST.SAGE_INTACCT_EXPORT_DATE.EXPORTED]: { + label: 'Export date', + description: 'Date the report was exported to Sage Intacct.', + }, + [CONST.SAGE_INTACCT_EXPORT_DATE.SUBMITTED]: { + label: 'Submitted date', + description: 'Date the report was submitted for approval.', + }, + }, + }, + reimbursableExpenses: { + label: 'Export reimbursable expenses as', + description: 'Reimbursable expenses will export as expense reports to Sage Intacct. Bills will export as vendor bills.', + values: { + [CONST.SAGE_INTACCT_REIMBURSABLE_EXPENSE_TYPE.EXPENSE_REPORT]: 'Expense reports', + [CONST.SAGE_INTACCT_REIMBURSABLE_EXPENSE_TYPE.VENDOR_BILL]: 'Vendor bills', + }, + }, + nonReimbursableExpenses: { + label: 'Export non-reimbursable expenses as', + description: + 'Non-reimbursable expenses will export to Sage Intacct as either credit card transactions or vendor bills and credit the account selected below. Learn more about assigning cards to individual accounts.', + values: { + [CONST.SAGE_INTACCT_NON_REIMBURSABLE_EXPENSE_TYPE.CREDIT_CARD_CHARGE]: 'Credit card transactions', + [CONST.SAGE_INTACCT_NON_REIMBURSABLE_EXPENSE_TYPE.VENDOR_BILL]: 'Vendor bills', + }, + }, + creditCardAccount: 'Credit card account', + defaultVendor: 'Default vendor', + defaultVendorDescription: (isReimbursable: boolean): string => + `Set a default vendor that will apply to ${isReimbursable ? '' : 'non-'}reimbursable expenses that don't have a matching vendor in Sage Intacct.`, + exportDescription: 'Configure how data in Expensify gets exported to Sage Intacct.', + exportPreferredExporterNote: + 'The preferred exporter can be any workspace admin, but must also be a Domain Admin if you set different export accounts for individual company cards in Domain Settings.', + exportPreferredExporterSubNote: 'Once set, the preferred exporter will see reports for export in their account.', + noAccountsFound: 'No accounts found', + noAccountsFoundDescription: `Add the account in Sage Intacct and sync the connection again.`, + autoSync: 'Auto-sync', + autoSyncDescription: 'Sync Sage Intacct and Expensify automatically, every day.', + inviteEmployees: 'Invite employees', + inviteEmployeesDescription: + 'Import Sage Intacct employee records and invite employees to this workspace. Your approval workflow will default to manager approval and can be furthered configured on the Members page.', + syncReimbursedReports: 'Sync reimbursed reports', + syncReimbursedReportsDescription: 'When a report is reimbursed using Expensify ACH, the corresponding puchase bill will be created in the Sage Intacct account below.', + paymentAccount: 'Sage Intacct payment account', + }, netsuite: { subsidiary: 'Subsidiary', subsidiarySelectDescription: "Choose the subsidiary in NetSuite that you'd like to import data from.", @@ -2284,6 +2352,59 @@ 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', + reimbursementsAccountDescription: "Choose the bank account you'll use for reimbursements, and we'll create the associated payment in NetSuite.", + collectionsAccount: 'Collections account', + collectionsAccountDescription: 'Once an invoice is marked as paid in Expensify and exported to NetSuite, it’ll appear against the account below.', + approvalAccount: 'A/P approval account', + approvalAccountDescription: + 'Choose the account that transactions will be approved against in NetSuite. If you’re syncing reimbursed reports, this is also the account that bill payments will be created against.', + 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', + description: 'Once an expense report is approved in Expensify and exported to NetSuite, you can set an additional level of approval in NetSuite prior to posting.', + 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', + description: 'Once a vendor bill is approved in Expensify and exported to NetSuite, you can set an additional level of approval in NetSuite prior to posting.', + 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', + description: 'Once a journal entry is approved in Expensify and exported to NetSuite, you can set an additional level of approval in NetSuite prior to posting.', + 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', + }, + }, + error: { + customFormID: 'Please enter a valid numeric custom form ID.', + }, + }, noAccountsFound: 'No accounts found', noAccountsFoundDescription: 'Add the account in NetSuite and sync the connection again.', noVendorsFound: 'No vendors found', @@ -2292,6 +2413,88 @@ 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: { + 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: { sageIntacctSetup: 'Sage Intacct setup', @@ -2309,6 +2512,20 @@ export default { control: 'Control', collect: 'Collect', }, + expensifyCard: { + issueCard: 'Issue card', + name: 'Name', + lastFour: 'Last 4', + limit: 'Limit', + currentBalance: 'Current balance', + currentBalanceDescription: 'Current balance is the sum of all posted Expensify Card transactions that have occurred since the last settlement date.', + remainingLimit: 'Remaining limit', + requestLimitIncrease: 'Request limit increase', + remainingLimitDescription: + 'We consider a number of factors when calculating your remaining limit: your tenure as a customer, the business-related information you provided during signup, and the available cash in your business bank account. Your remaining limit can fluctuate on a daily basis.', + cashBack: 'Cash back', + cashBackDescription: 'Cash back balance is based on settled monthly Expensify Card spend across your workspace.', + }, categories: { deleteCategories: 'Delete categories', deleteCategoriesPrompt: 'Are you sure you want to delete these categories?', @@ -2360,6 +2577,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', @@ -2400,9 +2627,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', @@ -2571,6 +2831,21 @@ export default { xero: 'Xero', netsuite: 'NetSuite', intacct: 'Sage Intacct', + connectionName: (integration: ConnectionName) => { + switch (integration) { + case CONST.POLICY.CONNECTIONS.NAME.QBO: + return 'Quickbooks Online'; + case CONST.POLICY.CONNECTIONS.NAME.XERO: + return 'Xero'; + case CONST.POLICY.CONNECTIONS.NAME.NETSUITE: + return 'NetSuite'; + case CONST.POLICY.CONNECTIONS.NAME.SAGE_INTACCT: + return 'Sage Intacct'; + default: { + return ''; + } + } + }, setup: 'Connect', lastSync: (relativeDate: string) => `Last synced ${relativeDate}`, import: 'Import', @@ -2610,6 +2885,7 @@ export default { [CONST.INTEGRATION_ENTITY_MAP_TYPES.NOT_IMPORTED]: 'Not imported', [CONST.INTEGRATION_ENTITY_MAP_TYPES.NONE]: 'Not imported', [CONST.INTEGRATION_ENTITY_MAP_TYPES.REPORT_FIELD]: 'Imported as report fields', + [CONST.INTEGRATION_ENTITY_MAP_TYPES.NETSUITE_DEFAULT]: 'NetSuite employee default', }, disconnectPrompt: (currentIntegration?: ConnectionName): string => { const integrationName = @@ -2731,6 +3007,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', @@ -2813,6 +3091,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', @@ -2891,6 +3171,30 @@ export default { errorDescriptionPartTwo: 'reach out to Concierge', errorDescriptionPartThree: 'for help.', }, + upgrade: { + reportFields: { + title: 'Report fields', + description: `Report fields let you specify header-level details, distinct from tags that pertain to expenses on individual line items. These details can encompass specific project names, business trip information, locations, and more.`, + pricing: { + onlyAvailableOnPlan: 'Report fields are only available on the Control plan, starting at ', + amount: '$9 ', + perActiveMember: 'per active member per month.', + }, + }, + note: { + upgradeWorkspace: 'Upgrade your workspace to access this feature, or', + learnMore: 'learn more', + aboutOurPlans: 'about our plans and pricing.', + }, + upgradeToUnlock: 'Unlock this feature', + completed: { + headline: `You've upgraded your workspace!`, + successMessage: (policyName: string) => `You've successfully upgraded your ${policyName} workspace to the Control plan!`, + viewSubscription: 'View your subscription', + moreDetails: 'for more details.', + gotIt: 'Got it, thanks', + }, + }, restrictedAction: { restricted: 'Restricted', actionsAreCurrentlyRestricted: ({workspaceName}) => `Actions on the ${workspaceName} workspace are currently restricted`, @@ -3024,6 +3328,12 @@ export default { }, }, groupedExpenses: 'grouped expenses', + bulkActions: { + delete: 'Delete', + hold: 'Hold', + unhold: 'Unhold', + noOptionsAvailable: 'No options available for the selected group of expenses.', + }, }, genericErrorPage: { title: 'Uh-oh, something went wrong!', @@ -3133,7 +3443,7 @@ export default { exportedToCSV: `exported this report to CSV`, exportedToIntegration: ({label}: ExportedToIntegrationParams) => `exported this report to ${label}`, forwarded: ({amount, currency}: ForwardedParams) => `approved ${currency}${amount}`, - integrationsMessage: ({errorMessage, label}: IntegrationsMessageParams) => `failed to export this report to ${label}. ${errorMessage}`, + integrationsMessage: (errorMessage: string, label: string) => `failed to export this report to ${label} ("${errorMessage}").`, managerAttachReceipt: `added a receipt`, managerDetachReceipt: `removed the receipt`, markedReimbursed: ({amount, currency}: MarkedReimbursedParams) => `paid ${currency}${amount} elsewhere`, @@ -3389,7 +3699,19 @@ export default { overLimitAttendee: ({formattedLimit}: ViolationsOverLimitParams) => `Amount over ${formattedLimit}/person limit`, perDayLimit: ({formattedLimit}: ViolationsPerDayLimitParams) => `Amount over daily ${formattedLimit}/person category limit`, receiptNotSmartScanned: 'Receipt not verified. Please confirm accuracy.', - receiptRequired: (params: ViolationsReceiptRequiredParams) => `Receipt required${params ? ` over ${params.formattedLimit}${params.category ? ' category limit' : ''}` : ''}`, + receiptRequired: ({formattedLimit, category}: ViolationsReceiptRequiredParams) => { + let message = 'Receipt required'; + if (formattedLimit ?? category) { + message += ' over'; + if (formattedLimit) { + message += ` ${formattedLimit}`; + } + if (category) { + message += ' category limit'; + } + } + return message; + }, reviewRequired: 'Review required', rter: ({brokenBankConnection, email, isAdmin, isTransactionOlderThan7Days, member}: ViolationsRterParams) => { if (brokenBankConnection) { @@ -3410,6 +3732,14 @@ export default { taxOutOfPolicy: ({taxName}: ViolationsTaxOutOfPolicyParams) => `${taxName ?? 'Tax'} no longer valid`, taxRateChanged: 'Tax rate was modified', taxRequired: 'Missing tax rate', + none: 'None', + taxCodeToKeep: 'Choose which tax code to keep', + tagToKeep: 'Choose which tag to keep', + isTransactionReimbursable: 'Choose if transaction is reimbursable', + merchantToKeep: 'Choose which merchant to keep', + descriptionToKeep: 'Choose which description to keep', + categoryToKeep: 'Choose which category to keep', + isTransactionBillable: 'Choose if transaction is billable', keepThisOne: 'Keep this one', hold: 'Hold', }, @@ -3463,6 +3793,9 @@ export default { }, subscription: { mobileReducedFunctionalityMessage: 'You can’t make changes to your subscription in the mobile app.', + badge: { + freeTrial: ({numOfDays}) => `Free trial: ${numOfDays} ${numOfDays === 1 ? 'day' : 'days'} left`, + }, billingBanner: { policyOwnerAmountOwed: { title: 'Your payment info is outdated', @@ -3516,7 +3849,11 @@ export default { preTrial: { title: 'Start a free trial', subtitle: 'To get started, ', - subtitleLink: 'complete your setup checklist here', + subtitleLink: 'complete your setup checklist here.', + }, + trialStarted: { + title: ({numOfDays}) => `Free trial: ${numOfDays} ${numOfDays === 1 ? 'day' : 'days'} left!`, + subtitle: 'Add a payment card to continue using all of your favorite features.', }, }, cardSection: { @@ -3530,6 +3867,13 @@ export default { changeCurrency: 'Change payment currency', cardNotFound: 'No payment card added', retryPaymentButton: 'Retry payment', + requestRefund: 'Request refund', + requestRefundModal: { + phrase1: 'Getting a refund is easy, just downgrade your account before your next billing date and you’ll receive a refund.', + phrase2: + 'Heads up: Downgrading your account means your workspace(s) will be deleted. This action can’t be undone, but you can always create a new workspace if you change your mind.', + confirm: 'Delete workspace(s) and downgrade', + }, viewPaymentHistory: 'View payment history', }, yourPlan: { diff --git a/src/languages/es.ts b/src/languages/es.ts index 87369d407d77..5e5dcf0057a8 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -15,6 +15,7 @@ import type { ChangePolicyParams, ChangeTypeParams, CharacterLimitParams, + ConfirmHoldExpenseParams, ConfirmThatParams, DateShouldBeAfterParams, DateShouldBeBeforeParams, @@ -33,7 +34,6 @@ import type { GoBackMessageParams, GoToRoomParams, InstantSummaryParams, - IntegrationsMessageParams, LocalTimeParams, LoggedInAsParams, LogSizeParams, @@ -344,9 +344,13 @@ export default { shared: 'Compartidos', drafts: 'Borradores', finished: 'Finalizados', + upgrade: 'Mejora', companyID: 'Empresa ID', userID: 'Usuario ID', disable: 'Deshabilitar', + initialValue: 'Valor inicial', + currentDate: 'Fecha actual', + value: 'Valor', }, connectionComplete: { title: 'Conexión completa', @@ -783,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', @@ -801,6 +817,11 @@ export default { removed: 'eliminó', transactionPending: 'Transacción pendiente.', chooseARate: ({unit}: ReimbursementRateParams) => `Selecciona una tasa de reembolso por ${unit} del espacio de trabajo`, + unapprove: 'Desaprobar', + unapproveReport: 'Anular la aprobación del informe', + headsUp: 'Atención!', + unapproveWithIntegrationWarning: (accountingIntegration: string) => + `Este informe ya se ha exportado a ${accountingIntegration}. Los cambios realizados en este informe en Expensify pueden provocar discrepancias en los datos y problemas de conciliación de la tarjeta Expensify. ¿Está seguro de que desea anular la aprobación de este informe?`, }, notificationPreferencesPage: { header: 'Preferencias de avisos', @@ -920,7 +941,7 @@ export default { timezonePage: { timezone: 'Zona horaria', isShownOnProfile: 'Tu zona horaria se muestra en tu perfil.', - getLocationAutomatically: 'Detecta tu ubicación automáticamente.', + getLocationAutomatically: 'Detecta tu ubicación automáticamente', }, updateRequiredView: { updateRequired: 'Actualización requerida', @@ -970,6 +991,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', @@ -1351,7 +1374,7 @@ export default { groupMembersListTitle: 'Directorio de los miembros del grupo.', lastMemberTitle: '¡Atención!', lastMemberWarning: 'Ya que eres la última persona aquí, si te vas, este chat quedará inaccesible para todos los miembros. ¿Estás seguro de que quieres salir del chat?', - defaultReportName: ({displayName}: {displayName: string}) => `Chat de group de ${displayName}`, + defaultReportName: ({displayName}: {displayName: string}) => `Chat de groupo de ${displayName}`, }, languagePage: { language: 'Idioma', @@ -1464,6 +1487,7 @@ export default { error: { containsReservedWord: 'El nombre no puede contener las palabras Expensify o Concierge.', hasInvalidCharacter: 'El nombre no puede contener una coma o un punto y coma.', + requiredFirstName: 'El nombre no puede estar vacío.', }, }, privatePersonalDetails: { @@ -2000,6 +2024,7 @@ export default { workspace: { common: { card: 'Tarjetas', + expensifyCard: 'Tarjeta Expensify', workflows: 'Flujos de trabajo', workspace: 'Espacio de trabajo', edit: 'Editar espacio de trabajo', @@ -2015,7 +2040,6 @@ export default { bills: 'Pagar facturas', invoices: 'Enviar facturas', travel: 'Viajes', - expensifyCard: 'Tarjeta Expensify', members: 'Miembros', accounting: 'Contabilidad', plan: 'Plan', @@ -2115,14 +2139,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 +2214,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', @@ -2235,6 +2253,65 @@ export default { noAccountsFound: 'No se ha encontrado ninguna cuenta', noAccountsFoundDescription: 'Añade la cuenta en Xero y sincroniza de nuevo la conexión.', }, + + sageIntacct: { + preferredExporter: 'Exportador preferido', + notConfigured: 'No configurado', + exportDate: { + label: 'Fecha de exportación', + description: 'Utilice esta fecha cuando exporte informes a Sage Intacct.', + values: { + [CONST.SAGE_INTACCT_EXPORT_DATE.LAST_EXPENSE]: { + label: 'Fecha del último gasto', + description: 'Fecha del gasto más reciente del informe.', + }, + [CONST.SAGE_INTACCT_EXPORT_DATE.EXPORTED]: { + label: 'Fecha de exportación', + description: 'Fecha en la que se exportó el informe a Sage Intacct.', + }, + [CONST.SAGE_INTACCT_EXPORT_DATE.SUBMITTED]: { + label: 'Fecha de envío', + description: 'Fecha de presentación del informe para su aprobación.', + }, + }, + }, + reimbursableExpenses: { + label: 'Gastos reembolsables de exportación como', + description: 'Los gastos reembolsables se exportarán como informes de gastos a Sage Intacct. Las facturas se exportarán como facturas de proveedores.', + values: { + [CONST.SAGE_INTACCT_REIMBURSABLE_EXPENSE_TYPE.EXPENSE_REPORT]: 'Informes de gastos', + [CONST.SAGE_INTACCT_REIMBURSABLE_EXPENSE_TYPE.VENDOR_BILL]: 'Facturas de proveedores', + }, + }, + nonReimbursableExpenses: { + label: 'Exportar gastos no reembolsables como', + description: + 'Los gastos no reembolsables se exportarán a Sage Intacct como transacciones de tarjetas de crédito o facturas de proveedores y se abonarán en la cuenta seleccionada a continuación. Más información sobre la asignación de tarjetas a cuentas individuales.', + values: { + [CONST.SAGE_INTACCT_NON_REIMBURSABLE_EXPENSE_TYPE.CREDIT_CARD_CHARGE]: 'Transacciones con tarjeta de crédito', + [CONST.SAGE_INTACCT_NON_REIMBURSABLE_EXPENSE_TYPE.VENDOR_BILL]: 'Facturas de proveedores', + }, + }, + creditCardAccount: 'Cuenta de tarjeta de crédito', + defaultVendor: 'Proveedor por defecto', + defaultVendorDescription: (isReimbursable: boolean): string => + `Establezca un proveedor predeterminado que se aplicará a los gastos ${isReimbursable ? '' : 'no '}reembolsables que no tienen un proveedor coincidente en Sage Intacct.`, + exportDescription: 'Configure cómo se exportan los datos de Expensify a Sage Intacct.', + exportPreferredExporterNote: + 'El exportador preferido puede ser cualquier administrador del área de trabajo, pero también debe ser un administrador del dominio si establece diferentes cuentas de exportación para tarjetas de empresa individuales en Configuración del dominio.', + exportPreferredExporterSubNote: 'Una vez configurado, el exportador preferido verá los informes para exportar en su cuenta.', + noAccountsFound: 'No se ha encontrado ninguna cuenta', + noAccountsFoundDescription: 'Añade la cuenta en Sage Intacct y sincroniza de nuevo la conexión.', + autoSync: 'Sincronización automática', + autoSyncDescription: 'Sincronice Sage Intacct y Expensify automáticamente, todos los días.', + inviteEmployees: 'Invitar a los empleados', + inviteEmployeesDescription: + 'Importe los registros de empleados de Sage Intacct e invite a los 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 aún más en la página Miembros.', + syncReimbursedReports: 'Sincronizar informes reembolsados', + syncReimbursedReportsDescription: + 'Cuando un informe se reembolsa utilizando Expensify ACH, la factura de compra correspondiente se creará en la cuenta de Sage Intacct a continuación.', + paymentAccount: 'Cuenta de pago Sage Intacct', + }, netsuite: { subsidiary: 'Subsidiaria', subsidiarySelectDescription: 'Elige la subsidiaria de NetSuite de la que deseas importar datos.', @@ -2317,6 +2394,62 @@ 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', + reimbursementsAccountDescription: 'Elija la cuenta bancaria que utilizará para los reembolsos y crearemos el pago asociado en NetSuite.', + collectionsAccount: 'Cuenta de cobros', + collectionsAccountDescription: 'Una vez que una factura se marca como pagada en Expensify y se exporta a NetSuite, aparecerá contra la cuenta de abajo.', + approvalAccount: 'Cuenta de aprobación de cuentas por pagar', + approvalAccountDescription: + 'Elija la cuenta con la que se aprobarán las transacciones en NetSuite. Si está sincronizando informes reembolsados, esta es también la cuenta con la que se crearán los pagos de facturas.', + 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', + description: + 'Una vez aprobado un informe de gastos en Expensify y exportado a NetSuite, puede establecer un nivel adicional de aprobación en NetSuite antes de su contabilización.', + 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', + description: + 'Una vez aprobada una factura de proveedor en Expensify y exportada a NetSuite, puede establecer un nivel adicional de aprobación en NetSuite antes de su contabilización.', + 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', + description: 'Una vez aprobado un asiento en Expensify y exportado a NetSuite, puede establecer un nivel adicional de aprobación en NetSuite antes de contabilizarlo.', + 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', + }, + }, + error: { + customFormID: 'Introduzca un ID numérico válido para el formulario personalizado.', + }, + }, 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,6 +2458,88 @@ 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: { + 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 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: { sageIntacctSetup: 'Sage Intacct configuración', @@ -2342,6 +2557,21 @@ export default { control: 'Control', collect: 'Recolectar', }, + expensifyCard: { + issueCard: 'Emitir tarjeta', + name: 'Nombre', + lastFour: '4 últimos', + limit: 'Limite', + currentBalance: 'Saldo actual', + currentBalanceDescription: + 'El saldo actual es la suma de todas las transacciones contabilizadas con la Tarjeta Expensify que se han producido desde la última fecha de liquidación.', + remainingLimit: 'Límite restante', + requestLimitIncrease: 'Solicitar aumento de límite', + remainingLimitDescription: + 'A la hora de calcular tu límite restante, tenemos en cuenta una serie de factores: su antigüedad como cliente, la información relacionada con tu negocio que nos facilitaste al darte de alta y el efectivo disponible en tu cuenta bancaria comercial. Tu límite restante puede fluctuar a diario.', + cashBack: 'Reembolso', + cashBackDescription: 'El saldo de devolución se basa en el gasto mensual realizado con la tarjeta Expensify en tu espacio de trabajo.', + }, categories: { deleteCategories: 'Eliminar categorías', deleteCategoriesPrompt: '¿Estás seguro de que quieres eliminar estas categorías?', @@ -2389,6 +2619,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', @@ -2433,9 +2673,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', @@ -2544,6 +2817,21 @@ export default { xero: 'Xero', netsuite: 'NetSuite', intacct: 'Sage Intacct', + connectionName: (integration: ConnectionName) => { + switch (integration) { + case CONST.POLICY.CONNECTIONS.NAME.QBO: + return 'Quickbooks Online'; + case CONST.POLICY.CONNECTIONS.NAME.XERO: + return 'Xero'; + case CONST.POLICY.CONNECTIONS.NAME.NETSUITE: + return 'NetSuite'; + case CONST.POLICY.CONNECTIONS.NAME.SAGE_INTACCT: + return 'Sage Intacct'; + default: { + return ''; + } + } + }, setup: 'Configurar', lastSync: (relativeDate: string) => `Recién sincronizado ${relativeDate}`, import: 'Importar', @@ -2582,6 +2870,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]: 'Predeterminado del empleado NetSuite', }, disconnectPrompt: (currentIntegration?: ConnectionName): string => { const integrationName = @@ -2703,6 +2992,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', @@ -2846,6 +3137,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', @@ -2925,6 +3218,30 @@ export default { errorDescriptionPartTwo: 'contacta con el conserje', errorDescriptionPartThree: 'por ayuda.', }, + upgrade: { + reportFields: { + title: 'Los campos', + description: `Los campos de informe permiten especificar detalles a nivel de cabecera, distintos de las etiquetas que pertenecen a los gastos en partidas individuales. Estos detalles pueden incluir nombres de proyectos específicos, información sobre viajes de negocios, ubicaciones, etc.`, + pricing: { + onlyAvailableOnPlan: 'Los campos de informe sólo están disponibles en el plan Control, a partir de ', + amount: '$9 ', + perActiveMember: 'por miembro activo al mes.', + }, + }, + note: { + upgradeWorkspace: 'Mejore su espacio de trabajo para acceder a esta función, o', + learnMore: 'más información', + aboutOurPlans: 'sobre nuestros planes y precios.', + }, + upgradeToUnlock: 'Desbloquear esta función', + completed: { + headline: 'Has mejorado tu espacio de trabajo.', + successMessage: (policyName: string) => `Ha mejorado correctamente su espacio de trabajo ${policyName} al plan Control.`, + viewSubscription: 'Ver su suscripción', + moreDetails: 'para obtener más información.', + gotIt: 'Entendido, gracias.', + }, + }, restrictedAction: { restricted: 'Restringido', actionsAreCurrentlyRestricted: ({workspaceName}) => `Las acciones en el espacio de trabajo ${workspaceName} están actualmente restringidas`, @@ -3059,6 +3376,12 @@ export default { }, }, groupedExpenses: 'gastos agrupados', + bulkActions: { + delete: 'Eliminar', + hold: 'Bloquear', + unhold: 'Desbloquear', + noOptionsAvailable: 'No hay opciones disponibles para el grupo de gastos seleccionado.', + }, }, genericErrorPage: { title: '¡Oh-oh, algo salió mal!', @@ -3169,7 +3492,7 @@ export default { exportedToCSV: `exportó este informe a CSV`, exportedToIntegration: ({label}: ExportedToIntegrationParams) => `exportó este informe a ${label}`, forwarded: ({amount, currency}: ForwardedParams) => `aprobado ${currency}${amount}`, - integrationsMessage: ({errorMessage, label}: IntegrationsMessageParams) => `no se pudo exportar este informe a ${label}. ${errorMessage}`, + integrationsMessage: (errorMessage: string, label: string) => `no se pudo exportar este informe a ${label} ("${errorMessage}").`, managerAttachReceipt: `agregó un recibo`, managerDetachReceipt: `quitó el recibo`, markedReimbursed: ({amount, currency}: MarkedReimbursedParams) => `pagó ${currency}${amount} en otro lugar`, @@ -3887,8 +4210,19 @@ export default { overLimitAttendee: ({formattedLimit}: ViolationsOverLimitParams) => `Importe supera el límite${formattedLimit ? ` de ${formattedLimit}/persona` : ''}`, perDayLimit: ({formattedLimit}: ViolationsPerDayLimitParams) => `Importe supera el límite diario de la categoría${formattedLimit ? ` de ${formattedLimit}/persona` : ''}`, receiptNotSmartScanned: 'Recibo no verificado. Por favor, confirma tu exactitud', - receiptRequired: (params: ViolationsReceiptRequiredParams) => - `Recibo obligatorio${params ? ` para importes sobre${params.formattedLimit ? ` ${params.formattedLimit}` : ''}${params.category ? ' el límite de la categoría' : ''}` : ''}`, + receiptRequired: ({formattedLimit, category}: ViolationsReceiptRequiredParams) => { + let message = 'Recibo obligatorio'; + if (formattedLimit ?? category) { + message += ' para importes sobre'; + if (formattedLimit) { + message += ` ${formattedLimit}`; + } + if (category) { + message += ' el límite de la categoría'; + } + } + return message; + }, reviewRequired: 'Revisión requerida', rter: ({brokenBankConnection, isAdmin, email, isTransactionOlderThan7Days, member}: ViolationsRterParams) => { if (brokenBankConnection) { @@ -3910,6 +4244,14 @@ export default { taxOutOfPolicy: ({taxName}: ViolationsTaxOutOfPolicyParams) => `${taxName ?? 'El impuesto'} ya no es válido`, taxRateChanged: 'La tasa de impuesto fue modificada', taxRequired: 'Falta la tasa de impuesto', + none: 'Ninguno', + taxCodeToKeep: 'Elige qué tasa de impuesto quieres conservar', + tagToKeep: 'Elige qué etiqueta quieres conservar', + isTransactionReimbursable: 'Elige si la transacción es reembolsable', + merchantToKeep: 'Elige qué comerciante quieres conservar', + descriptionToKeep: 'Elige qué descripción quieres conservar', + categoryToKeep: 'Elige qué categoría quieres conservar', + isTransactionBillable: 'Elige si la transacción es facturable', keepThisOne: 'Mantener éste', hold: 'Bloqueado', }, @@ -3963,6 +4305,9 @@ export default { }, subscription: { mobileReducedFunctionalityMessage: 'No puedes hacer cambios en tu suscripción en la aplicación móvil.', + badge: { + freeTrial: ({numOfDays}) => `Prueba gratuita: ${numOfDays === 1 ? `queda 1 día` : `quedan ${numOfDays} días`}`, + }, billingBanner: { policyOwnerAmountOwed: { title: 'Tu información de pago está desactualizada', @@ -4018,7 +4363,11 @@ export default { preTrial: { title: 'Iniciar una prueba gratuita', subtitle: 'Para empezar, ', - subtitleLink: 'completa la lista de configuración aquí', + subtitleLink: 'completa la lista de configuración aquí.', + }, + trialStarted: { + title: ({numOfDays}) => `Prueba gratuita: ¡${numOfDays === 1 ? `queda 1 día` : `quedan ${numOfDays} días`}!`, + subtitle: 'Añade una tarjeta de pago para seguir utilizando tus funciones favoritas.', }, }, cardSection: { @@ -4032,6 +4381,13 @@ export default { changeCurrency: 'Cambiar moneda de pago', cardNotFound: 'No se ha añadido ninguna tarjeta de pago', retryPaymentButton: 'Reintentar el pago', + requestRefund: 'Solicitar reembolso', + requestRefundModal: { + phrase1: 'Obtener un reembolso es fácil, simplemente baja tu cuenta de categoría antes de la próxima fecha de facturación y recibirás un reembolso.', + phrase2: + 'Atención: Bajar tu cuenta de categoría significa que tu(s) espacio(s) de trabajo será(n) eliminado(s). Esta acción no se puede deshacer, pero siempre puedes crear un nuevo espacio de trabajo si cambias de opinión.', + confirm: 'Eliminar y degradar', + }, viewPaymentHistory: 'Ver historial de pagos', }, yourPlan: { diff --git a/src/languages/types.ts b/src/languages/types.ts index eb90f2d9e0b2..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}; @@ -310,7 +312,18 @@ type ExportedToIntegrationParams = {label: string}; type ForwardedParams = {amount: string; currency: string}; -type IntegrationsMessageParams = {errorMessage: string; label: string}; +type IntegrationsMessageParams = { + label: string; + result: { + code?: number; + messages?: string[]; + title?: string; + link?: { + url: string; + text: string; + }; + }; +}; type MarkedReimbursedParams = {amount: string; currency: string}; @@ -339,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/CreateWorkspaceReportFieldListValueParams.ts b/src/libs/API/parameters/CreateWorkspaceReportFieldListValueParams.ts new file mode 100644 index 000000000000..950287bc5d04 --- /dev/null +++ b/src/libs/API/parameters/CreateWorkspaceReportFieldListValueParams.ts @@ -0,0 +1,10 @@ +type CreateWorkspaceReportFieldListValueParams = { + policyID: string; + /** + * Stringified JSON object with type of following structure: + * Array + */ + reportFields: string; +}; + +export default CreateWorkspaceReportFieldListValueParams; diff --git a/src/libs/API/parameters/CreateWorkspaceReportFieldParams.ts b/src/libs/API/parameters/CreateWorkspaceReportFieldParams.ts new file mode 100644 index 000000000000..33692d210959 --- /dev/null +++ b/src/libs/API/parameters/CreateWorkspaceReportFieldParams.ts @@ -0,0 +1,10 @@ +type CreateWorkspaceReportFieldParams = { + policyID: string; + /** + * Stringified JSON object with type of following structure: + * Array + */ + reportFields: string; +}; + +export default CreateWorkspaceReportFieldParams; diff --git a/src/libs/API/parameters/DeleteMoneyRequestOnSearchParams.ts b/src/libs/API/parameters/DeleteMoneyRequestOnSearchParams.ts new file mode 100644 index 000000000000..e44774ae671b --- /dev/null +++ b/src/libs/API/parameters/DeleteMoneyRequestOnSearchParams.ts @@ -0,0 +1,6 @@ +type DeleteMoneyRequestOnSearchParams = { + hash: number; + transactionIDList: string[]; +}; + +export default DeleteMoneyRequestOnSearchParams; diff --git a/src/libs/API/parameters/EnableWorkspaceReportFieldListValueParams.ts b/src/libs/API/parameters/EnableWorkspaceReportFieldListValueParams.ts new file mode 100644 index 000000000000..7c54a2f4c68b --- /dev/null +++ b/src/libs/API/parameters/EnableWorkspaceReportFieldListValueParams.ts @@ -0,0 +1,10 @@ +type EnableWorkspaceReportFieldListValueParams = { + policyID: string; + /** + * Stringified JSON object with type of following structure: + * Array + */ + reportFields: string; +}; + +export default EnableWorkspaceReportFieldListValueParams; diff --git a/src/libs/API/parameters/HoldMoneyRequestOnSearchParams.ts b/src/libs/API/parameters/HoldMoneyRequestOnSearchParams.ts new file mode 100644 index 000000000000..46ceed818cb8 --- /dev/null +++ b/src/libs/API/parameters/HoldMoneyRequestOnSearchParams.ts @@ -0,0 +1,7 @@ +type HoldMoneyRequestOnSearchParams = { + hash: number; + transactionIDList: string[]; + comment: string; +}; + +export default HoldMoneyRequestOnSearchParams; diff --git a/src/libs/API/parameters/OpenPolicyExpensifyCardsPageParams.ts b/src/libs/API/parameters/OpenPolicyExpensifyCardsPageParams.ts new file mode 100644 index 000000000000..c3c89857ab3b --- /dev/null +++ b/src/libs/API/parameters/OpenPolicyExpensifyCardsPageParams.ts @@ -0,0 +1,6 @@ +type OpenPolicyExpensifyCardsPageParams = { + policyID: string; + authToken: string | null | undefined; +}; + +export default OpenPolicyExpensifyCardsPageParams; diff --git a/src/libs/API/parameters/OpenPolicyInitialPageParams.ts b/src/libs/API/parameters/OpenPolicyInitialPageParams.ts new file mode 100644 index 000000000000..764abe9a6a77 --- /dev/null +++ b/src/libs/API/parameters/OpenPolicyInitialPageParams.ts @@ -0,0 +1,5 @@ +type OpenPolicyInitialPageParams = { + policyID: string; +}; + +export default OpenPolicyInitialPageParams; diff --git a/src/libs/API/parameters/OpenPolicyProfilePageParams.ts b/src/libs/API/parameters/OpenPolicyProfilePageParams.ts new file mode 100644 index 000000000000..55dce33a3dac --- /dev/null +++ b/src/libs/API/parameters/OpenPolicyProfilePageParams.ts @@ -0,0 +1,5 @@ +type OpenPolicyProfilePageParams = { + policyID: string; +}; + +export default OpenPolicyProfilePageParams; diff --git a/src/libs/API/parameters/PolicyReportFieldsReplace.ts b/src/libs/API/parameters/PolicyReportFieldsReplace.ts new file mode 100644 index 000000000000..c6d1834f0789 --- /dev/null +++ b/src/libs/API/parameters/PolicyReportFieldsReplace.ts @@ -0,0 +1,10 @@ +type PolicyReportFieldsReplace = { + policyID: string; + /** + * Stringified JSON object with type of following structure: + * Array + */ + reportFields: string; +}; + +export default PolicyReportFieldsReplace; diff --git a/src/libs/API/parameters/RemoveWorkspaceReportFieldListValueParams.ts b/src/libs/API/parameters/RemoveWorkspaceReportFieldListValueParams.ts new file mode 100644 index 000000000000..94d90a8dbaae --- /dev/null +++ b/src/libs/API/parameters/RemoveWorkspaceReportFieldListValueParams.ts @@ -0,0 +1,10 @@ +type RemoveWorkspaceReportFieldListValueParams = { + policyID: string; + /** + * Stringified JSON object with type of following structure: + * Array + */ + reportFields: string; +}; + +export default RemoveWorkspaceReportFieldListValueParams; diff --git a/src/libs/API/parameters/RequestExpensifyCardLimitIncreaseParams.ts b/src/libs/API/parameters/RequestExpensifyCardLimitIncreaseParams.ts new file mode 100644 index 000000000000..6e118f2a1c06 --- /dev/null +++ b/src/libs/API/parameters/RequestExpensifyCardLimitIncreaseParams.ts @@ -0,0 +1,6 @@ +type RequestExpensifyCardLimitIncreaseParams = { + authToken: string | null | undefined; + settlementBankAccountID: string; +}; + +export default RequestExpensifyCardLimitIncreaseParams; 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/UnapproveExpenseReportParams.ts b/src/libs/API/parameters/UnapproveExpenseReportParams.ts new file mode 100644 index 000000000000..ba25424aeda6 --- /dev/null +++ b/src/libs/API/parameters/UnapproveExpenseReportParams.ts @@ -0,0 +1,6 @@ +type UnapproveExpenseReportParams = { + reportID: string; + reportActionID: string; +}; + +export default UnapproveExpenseReportParams; diff --git a/src/libs/API/parameters/UnholdMoneyRequestOnSearchParams.ts b/src/libs/API/parameters/UnholdMoneyRequestOnSearchParams.ts new file mode 100644 index 000000000000..a32b57731999 --- /dev/null +++ b/src/libs/API/parameters/UnholdMoneyRequestOnSearchParams.ts @@ -0,0 +1,6 @@ +type UnholdMoneyRequestOnSearchParams = { + hash: number; + transactionIDList: string[]; +}; + +export default UnholdMoneyRequestOnSearchParams; diff --git a/src/libs/API/parameters/UpdateNetSuiteCustomFormIDParams.ts b/src/libs/API/parameters/UpdateNetSuiteCustomFormIDParams.ts new file mode 100644 index 000000000000..01e5db8c1089 --- /dev/null +++ b/src/libs/API/parameters/UpdateNetSuiteCustomFormIDParams.ts @@ -0,0 +1,10 @@ +import type {ValueOf} from 'type-fest'; +import type CONST from '@src/CONST'; + +type UpdateNetSuiteCustomFormIDParams = { + policyID: string; + formID: string; + formType: ValueOf; +}; + +export default UpdateNetSuiteCustomFormIDParams; diff --git a/src/libs/API/parameters/UpdateSageIntacctGenericTypeParams.ts b/src/libs/API/parameters/UpdateSageIntacctGenericTypeParams.ts new file mode 100644 index 000000000000..509a06cadb2c --- /dev/null +++ b/src/libs/API/parameters/UpdateSageIntacctGenericTypeParams.ts @@ -0,0 +1,7 @@ +type UpdateSageIntacctGenericTypeParams = { + [K2 in K]: Type; +} & { + policyID: string; +}; + +export default UpdateSageIntacctGenericTypeParams; diff --git a/src/libs/API/parameters/UpdateWorkspaceReportFieldInitialValueParams.ts b/src/libs/API/parameters/UpdateWorkspaceReportFieldInitialValueParams.ts new file mode 100644 index 000000000000..a72781ff1c37 --- /dev/null +++ b/src/libs/API/parameters/UpdateWorkspaceReportFieldInitialValueParams.ts @@ -0,0 +1,10 @@ +type UpdateWorkspaceReportFieldInitialValueParams = { + policyID: string; + /** + * Stringified JSON object with type of following structure: + * Array + */ + reportFields: string; +}; + +export default UpdateWorkspaceReportFieldInitialValueParams; diff --git a/src/libs/API/parameters/UpgradeToCorporateParams.ts b/src/libs/API/parameters/UpgradeToCorporateParams.ts new file mode 100644 index 000000000000..ee9d1359c4dd --- /dev/null +++ b/src/libs/API/parameters/UpgradeToCorporateParams.ts @@ -0,0 +1,6 @@ +type UpgradeToCorporateParams = { + policyID: string; + featureName: string; +}; + +export default UpgradeToCorporateParams; diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts index f032edf96e36..ff8465cfeec7 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -12,8 +12,11 @@ export type {default as BeginSignInParams} from './BeginSignInParams'; export type {default as CloseAccountParams} from './CloseAccountParams'; export type {default as ConnectBankAccountParams} from './ConnectBankAccountParams'; export type {default as ConnectPolicyToAccountingIntegrationParams} from './ConnectPolicyToAccountingIntegrationParams'; +export type {default as OpenPolicyProfilePageParams} from './OpenPolicyProfilePageParams'; +export type {default as OpenPolicyInitialPageParams} from './OpenPolicyInitialPageParams'; export type {default as SyncPolicyToQuickbooksOnlineParams} from './SyncPolicyToQuickbooksOnlineParams'; export type {default as SyncPolicyToXeroParams} from './SyncPolicyToXeroParams'; +export type {default as SyncPolicyToNetSuiteParams} from './SyncPolicyToNetSuiteParams'; export type {default as DeleteContactMethodParams} from './DeleteContactMethodParams'; export type {default as DeletePaymentBankAccountParams} from './DeletePaymentBankAccountParams'; export type {default as DeletePaymentCardParams} from './DeletePaymentCardParams'; @@ -150,6 +153,7 @@ export type {default as CreateDistanceRequestParams} from './CreateDistanceReque export type {default as StartSplitBillParams} from './StartSplitBillParams'; export type {default as SendMoneyParams} from './SendMoneyParams'; export type {default as ApproveMoneyRequestParams} from './ApproveMoneyRequestParams'; +export type {default as UnapproveExpenseReportParams} from './UnapproveExpenseReportParams'; export type {default as EditMoneyRequestParams} from './EditMoneyRequestParams'; export type {default as ReplaceReceiptParams} from './ReplaceReceiptParams'; export type {default as SubmitReportParams} from './SubmitReportParams'; @@ -233,5 +237,20 @@ export type {default as UpdateSubscriptionAutoRenewParams} from './UpdateSubscri export type {default as UpdateSubscriptionAddNewUsersAutomaticallyParams} from './UpdateSubscriptionAddNewUsersAutomaticallyParams'; export type {default as GenerateSpotnanaTokenParams} from './GenerateSpotnanaTokenParams'; export type {default as UpdateSubscriptionSizeParams} from './UpdateSubscriptionSizeParams'; +export type {default as UpgradeToCorporateParams} from './UpgradeToCorporateParams'; +export type {default as DeleteMoneyRequestOnSearchParams} from './DeleteMoneyRequestOnSearchParams'; +export type {default as HoldMoneyRequestOnSearchParams} from './HoldMoneyRequestOnSearchParams'; +export type {default as UnholdMoneyRequestOnSearchParams} from './UnholdMoneyRequestOnSearchParams'; export type {default as UpdateNetSuiteSubsidiaryParams} from './UpdateNetSuiteSubsidiaryParams'; +export type {default as PolicyReportFieldsReplace} from './PolicyReportFieldsReplace'; +export type {default as ConnectPolicyToNetSuiteParams} from './ConnectPolicyToNetSuiteParams'; +export type {default as CreateWorkspaceReportFieldParams} from './CreateWorkspaceReportFieldParams'; +export type {default as UpdateWorkspaceReportFieldInitialValueParams} from './UpdateWorkspaceReportFieldInitialValueParams'; +export type {default as EnableWorkspaceReportFieldListValueParams} from './EnableWorkspaceReportFieldListValueParams'; +export type {default as CreateWorkspaceReportFieldListValueParams} from './CreateWorkspaceReportFieldListValueParams'; +export type {default as RemoveWorkspaceReportFieldListValueParams} from './RemoveWorkspaceReportFieldListValueParams'; +export type {default as OpenPolicyExpensifyCardsPageParams} from './OpenPolicyExpensifyCardsPageParams'; +export type {default as RequestExpensifyCardLimitIncreaseParams} from './RequestExpensifyCardLimitIncreaseParams'; export type {default as UpdateNetSuiteGenericTypeParams} from './UpdateNetSuiteGenericTypeParams'; +export type {default as UpdateNetSuiteCustomFormIDParams} from './UpdateNetSuiteCustomFormIDParams'; +export type {default as UpdateSageIntacctGenericTypeParams} from './UpdateSageIntacctGenericTypeParams'; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index 1d6456f3df47..b7b85f98b599 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -5,7 +5,7 @@ import type * as Parameters from './parameters'; import type SignInUserParams from './parameters/SignInUserParams'; import type UpdateBeneficialOwnersForBankAccountParams from './parameters/UpdateBeneficialOwnersForBankAccountParams'; -type ApiRequest = ValueOf; +type ApiRequestType = ValueOf; const WRITE_COMMANDS = { SET_WORKSPACE_AUTO_REPORTING_FREQUENCY: 'SetWorkspaceAutoReportingFrequency', @@ -131,6 +131,7 @@ const WRITE_COMMANDS = { RENAME_POLICY_TAG: 'RenamePolicyTag', SET_WORKSPACE_REQUIRES_CATEGORY: 'SetWorkspaceRequiresCategory', DELETE_WORKSPACE_CATEGORIES: 'DeleteWorkspaceCategories', + POLICY_REPORT_FIELDS_REPLACE: 'Policy_ReportFields_Replace', SET_POLICY_TAGS_REQUIRED: 'SetPolicyTagsRequired', SET_POLICY_REQUIRES_TAG: 'SetPolicyRequiresTag', RENAME_POLICY_TAG_LIST: 'RenamePolicyTaglist', @@ -171,6 +172,7 @@ const WRITE_COMMANDS = { SEND_MONEY_ELSEWHERE: 'SendMoneyElsewhere', SEND_MONEY_WITH_WALLET: 'SendMoneyWithWallet', APPROVE_MONEY_REQUEST: 'ApproveMoneyRequest', + UNAPPROVE_EXPENSE_REPORT: 'UnapproveExpenseReport', EDIT_MONEY_REQUEST: 'EditMoneyRequest', REPLACE_RECEIPT: 'ReplaceReceipt', SUBMIT_REPORT: 'SubmitReport', @@ -229,7 +231,24 @@ const WRITE_COMMANDS = { UPDATE_SUBSCRIPTION_AUTO_RENEW: 'UpdateSubscriptionAutoRenew', UPDATE_SUBSCRIPTION_ADD_NEW_USERS_AUTOMATICALLY: 'UpdateSubscriptionAddNewUsersAutomatically', UPDATE_SUBSCRIPTION_SIZE: 'UpdateSubscriptionSize', + UPGRADE_TO_CORPORATE: 'UpgradeToCorporate', + DELETE_MONEY_REQUEST_ON_SEARCH: 'DeleteMoneyRequestOnSearch', + HOLD_MONEY_REQUEST_ON_SEARCH: 'HoldMoneyRequestOnSearch', + UNHOLD_MONEY_REQUEST_ON_SEARCH: 'UnholdMoneyRequestOnSearch', + REQUEST_REFUND: 'User_RefundPurchase', UPDATE_NETSUITE_SUBSIDIARY: 'UpdateNetSuiteSubsidiary', + CREATE_WORKSPACE_REPORT_FIELD: 'CreatePolicyReportField', + UPDATE_WORKSPACE_REPORT_FIELD_INITIAL_VALUE: 'SetPolicyReportFieldDefault', + ENABLE_WORKSPACE_REPORT_FIELD_LIST_VALUE: 'EnablePolicyReportFieldOption', + CREATE_WORKSPACE_REPORT_FIELD_LIST_VALUE: 'CreatePolicyReportFieldOption', + REMOVE_WORKSPACE_REPORT_FIELD_LIST_VALUE: 'RemovePolicyReportFieldOption', + 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', @@ -245,7 +264,37 @@ 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', + UPDATE_NETSUITE_REIMBURSEMENT_ACCOUNT_ID: 'UpdateNetSuiteReimbursementAccountID', + UPDATE_NETSUITE_COLLECTION_ACCOUNT: 'UpdateNetSuiteCollectionAccount', + UPDATE_NETSUITE_EXPORT_REPORTS_TO: 'UpdateNetSuiteExportReportsTo', + UPDATE_NETSUITE_VENDOR_BILLS_TO: 'UpdateNetSuiteExportVendorBillsTo', + UPDATE_NETSUITE_JOURNALS_TO: 'UpdateNetSuiteExportJournalsTo', + UPDATE_NETSUITE_APPROVAL_ACCOUNT: 'UpdateNetSuiteApprovalAccount', + UPDATE_NETSUITE_CUSTOM_FORM_ID_OPTIONS_REIMBURSABLE: 'UpdateNetSuiteCustomFormIDOptionsReimbursable', + UPDATE_NETSUITE_CUSTOM_FORM_ID_OPTIONS_NON_REIMBURSABLE: 'UpdateNetSuiteCustomFormIDOptionsNonReimbursable', + REQUEST_EXPENSIFY_CARD_LIMIT_INCREASE: 'RequestExpensifyCardLimitIncrease', CONNECT_POLICY_TO_SAGE_INTACCT: 'ConnectPolicyToSageIntacct', + UPDATE_SAGE_INTACCT_AUTO_SYNC: 'UpdateSageIntacctAutoSync', + UPDATE_SAGE_INTACCT_IMPORT_EMPLOYEES: 'UpdateSageIntacctImportEmployees', + UPDATE_SAGE_INTACCT_APPROVAL_MODE: 'UpdateSageIntacctApprovalMode', + UPDATE_SAGE_INTACCT_SYNC_REIMBURSED_REPORTS: 'UpdateSageIntacctSyncReimbursedReports', + UPDATE_SAGE_INTACCT_SYNC_REIMBURSEMENT_ACCOUNT_ID: 'UpdateSageIntacctSyncReimbursementAccountID', + CONNECT_POLICY_TO_NETSUITE: 'ConnectPolicyToNetSuite', + CLEAR_OUTSTANDING_BALANCE: 'ClearOutstandingBalance', + UPDATE_SAGE_INTACCT_EXPORTER: 'UpdateSageIntacctExporter', + UPDATE_SAGE_INTACCT_EXPORT_DATE: 'UpdateSageIntacctExportDate', + UPDATE_SAGE_INTACCT_REIMBURSABLE_EXPENSES_EXPORT_DESTINATION: 'UpdateSageIntacctReimbursableExpensesExportDestination', + UPDATE_SAGE_INTACCT_NON_REIMBURSABLE_EXPENSES_EXPORT_DESTINATION: 'UpdateSageIntacctNonreimbursableExpensesExportDestination', + UPDATE_SAGE_INTACCT_REIMBURSABLE_EXPENSES_REPORT_EXPORT_DEFAULT_VENDOR: 'UpdateSageIntacctReimbursableExpensesReportExportDefaultVendor', + UPDATE_SAGE_INTACCT_NON_REIMBURSABLE_EXPENSES_CREDIT_CARD_CHARGE_EXPORT_DEFAULT_VENDOR: 'UpdateSageIntacctNonreimbursableExpensesCreditCardChargeExportDefaultVendor', + UPDATE_SAGE_INTACCT_NON_REIMBURSABLE_EXPENSES_EXPORT_ACCOUNT: 'UpdateSageIntacctNonreimbursableExpensesExportAccount', + UPDATE_SAGE_INTACCT_NON_REIMBURSABLE_EXPENSES_EXPORT_VENDOR: 'UpdateSageIntacctNonreimbursableExpensesExportVendor', } as const; type WriteCommand = ValueOf; @@ -366,6 +415,7 @@ type WriteCommandParameters = { [WRITE_COMMANDS.RENAME_WORKSPACE_CATEGORY]: Parameters.RenameWorkspaceCategoriesParams; [WRITE_COMMANDS.SET_WORKSPACE_REQUIRES_CATEGORY]: Parameters.SetWorkspaceRequiresCategoryParams; [WRITE_COMMANDS.DELETE_WORKSPACE_CATEGORIES]: Parameters.DeleteWorkspaceCategoriesParams; + [WRITE_COMMANDS.POLICY_REPORT_FIELDS_REPLACE]: Parameters.PolicyReportFieldsReplace; [WRITE_COMMANDS.SET_POLICY_REQUIRES_TAG]: Parameters.SetPolicyRequiresTag; [WRITE_COMMANDS.SET_POLICY_TAGS_REQUIRED]: Parameters.SetPolicyTagsRequired; [WRITE_COMMANDS.RENAME_POLICY_TAG_LIST]: Parameters.RenamePolicyTaglistParams; @@ -408,6 +458,7 @@ type WriteCommandParameters = { [WRITE_COMMANDS.SEND_MONEY_ELSEWHERE]: Parameters.SendMoneyParams; [WRITE_COMMANDS.SEND_MONEY_WITH_WALLET]: Parameters.SendMoneyParams; [WRITE_COMMANDS.APPROVE_MONEY_REQUEST]: Parameters.ApproveMoneyRequestParams; + [WRITE_COMMANDS.UNAPPROVE_EXPENSE_REPORT]: Parameters.UnapproveExpenseReportParams; [WRITE_COMMANDS.EDIT_MONEY_REQUEST]: Parameters.EditMoneyRequestParams; [WRITE_COMMANDS.REPLACE_RECEIPT]: Parameters.ReplaceReceiptParams; [WRITE_COMMANDS.SUBMIT_REPORT]: Parameters.SubmitReportParams; @@ -449,6 +500,8 @@ type WriteCommandParameters = { [WRITE_COMMANDS.SET_POLICY_DISTANCE_RATES_UNIT]: Parameters.SetPolicyDistanceRatesUnitParams; [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; @@ -471,14 +524,50 @@ 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; + + [WRITE_COMMANDS.DELETE_MONEY_REQUEST_ON_SEARCH]: Parameters.DeleteMoneyRequestOnSearchParams; + [WRITE_COMMANDS.HOLD_MONEY_REQUEST_ON_SEARCH]: Parameters.HoldMoneyRequestOnSearchParams; + [WRITE_COMMANDS.UNHOLD_MONEY_REQUEST_ON_SEARCH]: Parameters.UnholdMoneyRequestOnSearchParams; + + [WRITE_COMMANDS.REQUEST_REFUND]: null; [WRITE_COMMANDS.CONNECT_POLICY_TO_SAGE_INTACCT]: Parameters.ConnectPolicyToSageIntacctParams; + [WRITE_COMMANDS.UPDATE_SAGE_INTACCT_EXPORTER]: Parameters.UpdateSageIntacctGenericTypeParams<'email', string>; + [WRITE_COMMANDS.UPDATE_SAGE_INTACCT_EXPORT_DATE]: Parameters.UpdateSageIntacctGenericTypeParams<'value', string>; + [WRITE_COMMANDS.UPDATE_SAGE_INTACCT_REIMBURSABLE_EXPENSES_EXPORT_DESTINATION]: Parameters.UpdateSageIntacctGenericTypeParams<'value', string>; + [WRITE_COMMANDS.UPDATE_SAGE_INTACCT_NON_REIMBURSABLE_EXPENSES_EXPORT_DESTINATION]: Parameters.UpdateSageIntacctGenericTypeParams<'value', string>; + [WRITE_COMMANDS.UPDATE_SAGE_INTACCT_REIMBURSABLE_EXPENSES_REPORT_EXPORT_DEFAULT_VENDOR]: Parameters.UpdateSageIntacctGenericTypeParams<'vendorID', string>; + [WRITE_COMMANDS.UPDATE_SAGE_INTACCT_NON_REIMBURSABLE_EXPENSES_CREDIT_CARD_CHARGE_EXPORT_DEFAULT_VENDOR]: Parameters.UpdateSageIntacctGenericTypeParams<'vendorID', string>; + [WRITE_COMMANDS.UPDATE_SAGE_INTACCT_NON_REIMBURSABLE_EXPENSES_EXPORT_ACCOUNT]: Parameters.UpdateSageIntacctGenericTypeParams<'creditCardAccountID', string>; + [WRITE_COMMANDS.UPDATE_SAGE_INTACCT_NON_REIMBURSABLE_EXPENSES_EXPORT_VENDOR]: Parameters.UpdateSageIntacctGenericTypeParams<'vendorID', string>; + [WRITE_COMMANDS.UPDATE_SAGE_INTACCT_AUTO_SYNC]: Parameters.UpdateSageIntacctGenericTypeParams<'enabled', boolean>; + [WRITE_COMMANDS.UPDATE_SAGE_INTACCT_IMPORT_EMPLOYEES]: Parameters.UpdateSageIntacctGenericTypeParams<'enabled', boolean>; + [WRITE_COMMANDS.UPDATE_SAGE_INTACCT_APPROVAL_MODE]: Parameters.UpdateSageIntacctGenericTypeParams<'value', string>; + [WRITE_COMMANDS.UPDATE_SAGE_INTACCT_SYNC_REIMBURSED_REPORTS]: Parameters.UpdateSageIntacctGenericTypeParams<'enabled', boolean>; + [WRITE_COMMANDS.UPDATE_SAGE_INTACCT_SYNC_REIMBURSEMENT_ACCOUNT_ID]: Parameters.UpdateSageIntacctGenericTypeParams<'vendorID', string>; + + [WRITE_COMMANDS.UPGRADE_TO_CORPORATE]: Parameters.UpgradeToCorporateParams; // Netsuite parameters [WRITE_COMMANDS.UPDATE_NETSUITE_SUBSIDIARY]: Parameters.UpdateNetSuiteSubsidiaryParams; + [WRITE_COMMANDS.CONNECT_POLICY_TO_NETSUITE]: Parameters.ConnectPolicyToNetSuiteParams; + + // Workspace report field parameters + [WRITE_COMMANDS.CREATE_WORKSPACE_REPORT_FIELD]: Parameters.CreateWorkspaceReportFieldParams; + [WRITE_COMMANDS.UPDATE_WORKSPACE_REPORT_FIELD_INITIAL_VALUE]: Parameters.UpdateWorkspaceReportFieldInitialValueParams; + [WRITE_COMMANDS.ENABLE_WORKSPACE_REPORT_FIELD_LIST_VALUE]: Parameters.EnableWorkspaceReportFieldListValueParams; + [WRITE_COMMANDS.CREATE_WORKSPACE_REPORT_FIELD_LIST_VALUE]: Parameters.CreateWorkspaceReportFieldListValueParams; + [WRITE_COMMANDS.REMOVE_WORKSPACE_REPORT_FIELD_LIST_VALUE]: Parameters.RemoveWorkspaceReportFieldListValueParams; + + [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>; @@ -494,6 +583,20 @@ 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>; + [WRITE_COMMANDS.UPDATE_NETSUITE_REIMBURSEMENT_ACCOUNT_ID]: Parameters.UpdateNetSuiteGenericTypeParams<'bankAccountID', string>; + [WRITE_COMMANDS.UPDATE_NETSUITE_COLLECTION_ACCOUNT]: Parameters.UpdateNetSuiteGenericTypeParams<'bankAccountID', string>; + [WRITE_COMMANDS.UPDATE_NETSUITE_EXPORT_REPORTS_TO]: Parameters.UpdateNetSuiteGenericTypeParams<'value', ValueOf>; + [WRITE_COMMANDS.UPDATE_NETSUITE_VENDOR_BILLS_TO]: Parameters.UpdateNetSuiteGenericTypeParams<'value', ValueOf>; + [WRITE_COMMANDS.UPDATE_NETSUITE_JOURNALS_TO]: Parameters.UpdateNetSuiteGenericTypeParams<'value', ValueOf>; + [WRITE_COMMANDS.UPDATE_NETSUITE_APPROVAL_ACCOUNT]: Parameters.UpdateNetSuiteGenericTypeParams<'value', string>; + [WRITE_COMMANDS.UPDATE_NETSUITE_CUSTOM_FORM_ID_OPTIONS_REIMBURSABLE]: Parameters.UpdateNetSuiteCustomFormIDParams; + [WRITE_COMMANDS.UPDATE_NETSUITE_CUSTOM_FORM_ID_OPTIONS_NON_REIMBURSABLE]: Parameters.UpdateNetSuiteCustomFormIDParams; }; const READ_COMMANDS = { @@ -501,6 +604,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', @@ -532,12 +636,15 @@ const READ_COMMANDS = { OPEN_POLICY_CATEGORIES_PAGE: 'OpenPolicyCategoriesPage', OPEN_POLICY_TAGS_PAGE: 'OpenPolicyTagsPage', OPEN_POLICY_TAXES_PAGE: 'OpenPolicyTaxesPage', + OPEN_POLICY_EXPENSIFY_CARDS_PAGE: 'OpenPolicyExpensifyCardsPage', OPEN_WORKSPACE_INVITE_PAGE: 'OpenWorkspaceInvitePage', OPEN_DRAFT_WORKSPACE_REQUEST: 'OpenDraftWorkspaceRequest', OPEN_POLICY_WORKFLOWS_PAGE: 'OpenPolicyWorkflowsPage', OPEN_POLICY_DISTANCE_RATES_PAGE: 'OpenPolicyDistanceRatesPage', OPEN_POLICY_MORE_FEATURES_PAGE: 'OpenPolicyMoreFeaturesPage', OPEN_POLICY_ACCOUNTING_PAGE: 'OpenPolicyAccountingPage', + OPEN_POLICY_PROFILE_PAGE: 'OpenPolicyProfilePage', + OPEN_POLICY_INITIAL_PAGE: 'OpenPolicyInitialPage', SEARCH: 'Search', OPEN_SUBSCRIPTION_PAGE: 'OpenSubscriptionPage', } as const; @@ -549,6 +656,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; @@ -586,6 +694,9 @@ type ReadCommandParameters = { [READ_COMMANDS.OPEN_POLICY_DISTANCE_RATES_PAGE]: Parameters.OpenPolicyDistanceRatesPageParams; [READ_COMMANDS.OPEN_POLICY_MORE_FEATURES_PAGE]: Parameters.OpenPolicyMoreFeaturesPageParams; [READ_COMMANDS.OPEN_POLICY_ACCOUNTING_PAGE]: Parameters.OpenPolicyAccountingPageParams; + [READ_COMMANDS.OPEN_POLICY_EXPENSIFY_CARDS_PAGE]: Parameters.OpenPolicyExpensifyCardsPageParams; + [READ_COMMANDS.OPEN_POLICY_PROFILE_PAGE]: Parameters.OpenPolicyProfilePageParams; + [READ_COMMANDS.OPEN_POLICY_INITIAL_PAGE]: Parameters.OpenPolicyInitialPageParams; [READ_COMMANDS.SEARCH]: Parameters.SearchParams; [READ_COMMANDS.OPEN_SUBSCRIPTION_PAGE]: null; }; @@ -623,4 +734,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/CardUtils.ts b/src/libs/CardUtils.ts index 106debd0a7e5..6f80a8a20a6b 100644 --- a/src/libs/CardUtils.ts +++ b/src/libs/CardUtils.ts @@ -139,6 +139,10 @@ function hasDetectedFraud(cardList: Record): boolean { return Object.values(cardList).some((card) => card.fraud !== CONST.EXPENSIFY_CARD.FRAUD_TYPES.NONE); } +function getMCardNumberString(cardNumber: string): string { + return cardNumber.replace(/\s/g, ''); +} + export { isExpensifyCard, isCorporateCard, @@ -150,4 +154,5 @@ export { getCardDescription, findPhysicalCard, hasDetectedFraud, + getMCardNumberString, }; 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/CurrencyUtils.ts b/src/libs/CurrencyUtils.ts index 7b54fbf0bed7..862b0ae5e928 100644 --- a/src/libs/CurrencyUtils.ts +++ b/src/libs/CurrencyUtils.ts @@ -87,8 +87,9 @@ function convertToBackendAmount(amountAsFloat: number): number { * * @note we do not support any currencies with more than two decimal places. */ -function convertToFrontendAmountAsInteger(amountAsInt: number): number { - return Math.trunc(amountAsInt) / 100.0; +function convertToFrontendAmountAsInteger(amountAsInt: number, currency: string = CONST.CURRENCY.USD): number { + const decimals = getCurrencyDecimals(currency); + return Number((Math.trunc(amountAsInt) / 100.0).toFixed(decimals)); } /** @@ -96,11 +97,12 @@ function convertToFrontendAmountAsInteger(amountAsInt: number): number { * * @note we do not support any currencies with more than two decimal places. */ -function convertToFrontendAmountAsString(amountAsInt: number | null | undefined): string { +function convertToFrontendAmountAsString(amountAsInt: number | null | undefined, currency: string = CONST.CURRENCY.USD, withDecimals = true): string { if (amountAsInt === null || amountAsInt === undefined) { return ''; } - return convertToFrontendAmountAsInteger(amountAsInt).toFixed(2); + const decimals = withDecimals ? getCurrencyDecimals(currency) : 0; + return convertToFrontendAmountAsInteger(amountAsInt, currency).toFixed(decimals); } /** @@ -111,7 +113,7 @@ function convertToFrontendAmountAsString(amountAsInt: number | null | undefined) * @param currency - IOU currency */ function convertToDisplayString(amountInCents = 0, currency: string = CONST.CURRENCY.USD): string { - const convertedAmount = convertToFrontendAmountAsInteger(amountInCents); + const convertedAmount = convertToFrontendAmountAsInteger(amountInCents, currency); /** * Fallback currency to USD if it empty string or undefined */ @@ -137,7 +139,7 @@ function convertToDisplayString(amountInCents = 0, currency: string = CONST.CURR * @param currency - IOU currency */ function convertToShortDisplayString(amountInCents = 0, currency: string = CONST.CURRENCY.USD): string { - const convertedAmount = convertToFrontendAmountAsInteger(amountInCents); + const convertedAmount = convertToFrontendAmountAsInteger(amountInCents, currency); return NumberFormatUtils.format(BaseLocaleListener.getPreferredLocale(), convertedAmount, { style: 'currency', @@ -168,7 +170,7 @@ function convertAmountToDisplayString(amount = 0, currency: string = CONST.CURRE * Acts the same as `convertAmountToDisplayString` but the result string does not contain currency */ function convertToDisplayStringWithoutCurrency(amountInCents: number, currency: string = CONST.CURRENCY.USD) { - const convertedAmount = convertToFrontendAmountAsInteger(amountInCents); + const convertedAmount = convertToFrontendAmountAsInteger(amountInCents, currency); return NumberFormatUtils.formatToParts(BaseLocaleListener.getPreferredLocale(), convertedAmount, { style: 'currency', currency, 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/DistanceRequestUtils.ts b/src/libs/DistanceRequestUtils.ts index dda5427e9c9f..a85db2bc28d8 100644 --- a/src/libs/DistanceRequestUtils.ts +++ b/src/libs/DistanceRequestUtils.ts @@ -1,15 +1,16 @@ -import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; +import type {OnyxEntry} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; import type {LocaleContextProps} from '@components/LocaleContextProvider'; import type {RateAndUnit} from '@src/CONST'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {LastSelectedDistanceRates, OnyxInputOrEntry, Report} from '@src/types/onyx'; +import type {LastSelectedDistanceRates, OnyxInputOrEntry} from '@src/types/onyx'; import type {Unit} from '@src/types/onyx/Policy'; import type Policy from '@src/types/onyx/Policy'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import * as CurrencyUtils from './CurrencyUtils'; import * as PolicyUtils from './PolicyUtils'; +import * as ReportConnection from './ReportConnection'; import * as ReportUtils from './ReportUtils'; type MileageRate = { @@ -28,13 +29,6 @@ Onyx.connect({ }, }); -let allReports: OnyxCollection; -Onyx.connect({ - key: ONYXKEYS.COLLECTION.REPORT, - waitForCollectionCallback: true, - callback: (value) => (allReports = value), -}); - const METERS_TO_KM = 0.001; // 1 kilometer is 1000 meters const METERS_TO_MILES = 0.000621371; // There are approximately 0.000621371 miles in a meter @@ -251,6 +245,7 @@ function convertToDistanceInMeters(distance: number, unit: Unit): number { * Returns custom unit rate ID for the distance transaction */ function getCustomUnitRateID(reportID: string) { + const allReports = ReportConnection.getAllReports(); const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; const parentReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${report?.parentReportID}`]; const policy = PolicyUtils.getPolicy(report?.policyID ?? parentReport?.policyID ?? '-1'); 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/Log.ts b/src/libs/Log.ts index 64271dee2265..83965807263a 100644 --- a/src/libs/Log.ts +++ b/src/libs/Log.ts @@ -2,7 +2,7 @@ // action would likely cause confusion about which one to use. But most other API methods should happen inside an action file. /* eslint-disable rulesdir/no-api-in-views */ -import {ExpensiMark, Logger} from 'expensify-common'; +import {Logger} from 'expensify-common'; import Onyx from 'react-native-onyx'; import type {Merge} from 'type-fest'; import CONST from '@src/CONST'; @@ -80,6 +80,5 @@ const Log = new Logger({ isDebug: true, }); timeout = setTimeout(() => Log.info('Flushing logs older than 10 minutes', true, {}, true), 10 * 60 * 1000); -ExpensiMark.setLogger(Log); export default Log; 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/ModifiedExpenseMessage.ts b/src/libs/ModifiedExpenseMessage.ts index efcba4c23204..38562edb7704 100644 --- a/src/libs/ModifiedExpenseMessage.ts +++ b/src/libs/ModifiedExpenseMessage.ts @@ -2,12 +2,13 @@ import Onyx from 'react-native-onyx'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {PolicyTagList, Report, ReportAction} from '@src/types/onyx'; +import type {PolicyTagList, ReportAction} from '@src/types/onyx'; import * as CurrencyUtils from './CurrencyUtils'; import DateUtils from './DateUtils'; import * as Localize from './Localize'; import * as PolicyUtils from './PolicyUtils'; import * as ReportActionsUtils from './ReportActionsUtils'; +import * as ReportConnection from './ReportConnection'; import * as TransactionUtils from './TransactionUtils'; let allPolicyTags: OnyxCollection = {}; @@ -23,13 +24,6 @@ Onyx.connect({ }, }); -let allReports: OnyxCollection; -Onyx.connect({ - key: ONYXKEYS.COLLECTION.REPORT, - waitForCollectionCallback: true, - callback: (value) => (allReports = value), -}); - /** * Builds the partial message fragment for a modified field on the expense. */ @@ -116,7 +110,7 @@ function getForReportAction(reportID: string | undefined, reportAction: OnyxEntr return ''; } const reportActionOriginalMessage = ReportActionsUtils.getOriginalMessage(reportAction); - const policyID = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]?.policyID ?? '-1'; + const policyID = ReportConnection.getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]?.policyID ?? '-1'; const removalFragments: string[] = []; const setFragments: string[] = []; diff --git a/src/libs/Navigation/AppNavigator/AuthScreens.tsx b/src/libs/Navigation/AppNavigator/AuthScreens.tsx index 4bf7e208590a..0e40fa4d4037 100644 --- a/src/libs/Navigation/AppNavigator/AuthScreens.tsx +++ b/src/libs/Navigation/AppNavigator/AuthScreens.tsx @@ -4,7 +4,6 @@ import type {OnyxEntry} from 'react-native-onyx'; import Onyx, {withOnyx} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; import OptionsListContextProvider from '@components/OptionListContextProvider'; -import useLastAccessedReportID from '@hooks/useLastAccessedReportID'; import useOnboardingLayout from '@hooks/useOnboardingLayout'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -83,7 +82,7 @@ function shouldOpenOnAdminRoom() { return url ? new URL(url).searchParams.get('openOnAdminRoom') === 'true' : false; } -function getCentralPaneScreenInitialParams(screenName: CentralPaneName, lastAccessedReportID?: string): Partial> { +function getCentralPaneScreenInitialParams(screenName: CentralPaneName): Partial> { if (screenName === SCREENS.SEARCH.CENTRAL_PANE) { return {sortBy: CONST.SEARCH.TABLE_COLUMNS.DATE, sortOrder: CONST.SEARCH.SORT_ORDER.DESC}; } @@ -91,7 +90,6 @@ function getCentralPaneScreenInitialParams(screenName: CentralPaneName, lastAcce if (screenName === SCREENS.REPORT) { return { openOnAdminRoom: shouldOpenOnAdminRoom() ? true : undefined, - reportID: lastAccessedReportID, }; } @@ -198,7 +196,6 @@ function AuthScreens({session, lastOpenedPublicRoomID, initialLastUpdateIDApplie const StyleUtils = useStyleUtils(); const {isSmallScreenWidth} = useWindowDimensions(); const {shouldUseNarrowLayout} = useOnboardingLayout(); - const lastAccessedReportID = useLastAccessedReportID(shouldOpenOnAdminRoom()); const screenOptions = getRootNavigatorScreenOptions(isSmallScreenWidth, styles, StyleUtils); const onboardingModalScreenOptions = useMemo(() => screenOptions.onboardingModalNavigator(shouldUseNarrowLayout), [screenOptions, shouldUseNarrowLayout]); const onboardingScreenOptions = useMemo( @@ -319,7 +316,7 @@ function AuthScreens({session, lastOpenedPublicRoomID, initialLastUpdateIDApplie }; // Rule disabled because this effect is only for component did mount & will component unmount lifecycle event - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps }, []); const CentralPaneScreenOptions = { @@ -474,7 +471,7 @@ function AuthScreens({session, lastOpenedPublicRoomID, initialLastUpdateIDApplie diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index 417bc4e8b983..cff9ddf3ab39 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -183,7 +183,7 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../../pages/settings/Profile/TimezoneSelectPage').default, [SCREENS.SETTINGS.PROFILE.LEGAL_NAME]: () => require('../../../../pages/settings/Profile/PersonalDetails/LegalNamePage').default, [SCREENS.SETTINGS.PROFILE.DATE_OF_BIRTH]: () => require('../../../../pages/settings/Profile/PersonalDetails/DateOfBirthPage').default, - [SCREENS.SETTINGS.PROFILE.ADDRESS]: () => require('../../../../pages/settings/Profile/PersonalDetails/AddressPage').default, + [SCREENS.SETTINGS.PROFILE.ADDRESS]: () => require('../../../../pages/settings/Profile/PersonalDetails/PersonalAddressPage').default, [SCREENS.SETTINGS.PROFILE.ADDRESS_COUNTRY]: () => require('../../../../pages/settings/Profile/PersonalDetails/CountrySelectionPage').default, [SCREENS.SETTINGS.PROFILE.ADDRESS_STATE]: () => require('../../../../pages/settings/Profile/PersonalDetails/StateSelectionPage').default, [SCREENS.SETTINGS.PROFILE.CONTACT_METHODS]: () => require('../../../../pages/settings/Profile/Contacts/ContactMethodsPage').default, @@ -197,7 +197,7 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../../pages/settings/AppDownloadLinks').default, [SCREENS.SETTINGS.CONSOLE]: () => require('../../../../pages/settings/AboutPage/ConsolePage').default, [SCREENS.SETTINGS.SHARE_LOG]: () => require('../../../../pages/settings/AboutPage/ShareLogPage').default, - [SCREENS.SETTINGS.WALLET.CARDS_DIGITAL_DETAILS_UPDATE_ADDRESS]: () => require('../../../../pages/settings/Profile/PersonalDetails/AddressPage').default, + [SCREENS.SETTINGS.WALLET.CARDS_DIGITAL_DETAILS_UPDATE_ADDRESS]: () => require('../../../../pages/settings/Profile/PersonalDetails/PersonalAddressPage').default, [SCREENS.SETTINGS.WALLET.DOMAIN_CARD]: () => require('../../../../pages/settings/Wallet/ExpensifyCardPage').default, [SCREENS.SETTINGS.WALLET.REPORT_VIRTUAL_CARD_FRAUD]: () => require('../../../../pages/settings/Wallet/ReportVirtualCardFraudPage').default, [SCREENS.SETTINGS.WALLET.CARD_ACTIVATE]: () => require('../../../../pages/settings/Wallet/ActivatePhysicalCardPage').default, @@ -230,6 +230,7 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../../pages/workspace/categories/CategorySettingsPage').default, [SCREENS.WORKSPACE.ADDRESS]: () => require('../../../../pages/workspace/WorkspaceProfileAddressPage').default, [SCREENS.WORKSPACE.CATEGORIES_SETTINGS]: () => require('../../../../pages/workspace/categories/WorkspaceCategoriesSettingsPage').default, + [SCREENS.WORKSPACE.UPGRADE]: () => require('../../../../pages/workspace/upgrade/WorkspaceUpgradePage').default, [SCREENS.WORKSPACE.MEMBER_DETAILS]: () => require('../../../../pages/workspace/members/WorkspaceMemberDetailsPage').default, [SCREENS.WORKSPACE.OWNER_CHANGE_CHECK]: () => require('@pages/workspace/members/WorkspaceOwnerChangeWrapperPage').default, [SCREENS.WORKSPACE.OWNER_CHANGE_SUCCESS]: () => require('../../../../pages/workspace/members/WorkspaceOwnerChangeSuccessPage').default, @@ -320,6 +321,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, @@ -343,11 +352,40 @@ 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.NETSUITE_REIMBURSEMENT_ACCOUNT_SELECT]: () => + require('../../../../pages/workspace/accounting/netsuite/advanced/NetSuiteReimbursementAccountSelectPage').default, + [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_COLLECTION_ACCOUNT_SELECT]: () => + require('../../../../pages/workspace/accounting/netsuite/advanced/NetSuiteCollectionAccountSelectPage').default, + [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_EXPENSE_REPORT_APPROVAL_LEVEL_SELECT]: () => + require('../../../../pages/workspace/accounting/netsuite/advanced/NetSuiteExpenseReportApprovalLevelSelectPage').default, + [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_VENDOR_BILL_APPROVAL_LEVEL_SELECT]: () => + require('../../../../pages/workspace/accounting/netsuite/advanced/NetSuiteVendorBillApprovalLevelSelectPage').default, + [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_JOURNAL_ENTRY_APPROVAL_LEVEL_SELECT]: () => + require('../../../../pages/workspace/accounting/netsuite/advanced/NetSuiteJournalEntryApprovalLevelSelectPage').default, + [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_APPROVAL_ACCOUNT_SELECT]: () => + require('../../../../pages/workspace/accounting/netsuite/advanced/NetSuiteApprovalAccountSelectPage').default, + [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_CUSTOM_FORM_ID]: () => require('../../../../pages/workspace/accounting/netsuite/advanced/NetSuiteCustomFormIDPage').default, [SCREENS.WORKSPACE.ACCOUNTING.SAGE_INTACCT_PREREQUISITES]: () => require('../../../../pages/workspace/accounting/intacct/IntacctPrerequisitesPage').default, [SCREENS.WORKSPACE.ACCOUNTING.ENTER_SAGE_INTACCT_CREDENTIALS]: () => require('../../../../pages/workspace/accounting/intacct/EnterSageIntacctCredentialsPage').default, [SCREENS.WORKSPACE.ACCOUNTING.EXISTING_SAGE_INTACCT_CONNECTIONS]: () => require('../../../../pages/workspace/accounting/intacct/ExistingConnectionsPage').default, + [SCREENS.WORKSPACE.ACCOUNTING.SAGE_INTACCT_EXPORT]: () => require('../../../../pages/workspace/accounting/intacct/export/SageIntacctExportPage').default, + [SCREENS.WORKSPACE.ACCOUNTING.SAGE_INTACCT_PREFERRED_EXPORTER]: () => + require('../../../../pages/workspace/accounting/intacct/export/SageIntacctPreferredExporterPage').default, + [SCREENS.WORKSPACE.ACCOUNTING.SAGE_INTACCT_EXPORT_DATE]: () => require('../../../../pages/workspace/accounting/intacct/export/SageIntacctDatePage').default, + [SCREENS.WORKSPACE.ACCOUNTING.SAGE_INTACCT_REIMBURSABLE_EXPENSES]: () => + require('../../../../pages/workspace/accounting/intacct/export/SageIntacctReimbursableExpensesPage').default, + [SCREENS.WORKSPACE.ACCOUNTING.SAGE_INTACCT_NON_REIMBURSABLE_EXPENSES]: () => + require('../../../../pages/workspace/accounting/intacct/export/SageIntacctNonReimbursableExpensesPage').default, + [SCREENS.WORKSPACE.ACCOUNTING.SAGE_INTACCT_DEFAULT_VENDOR]: () => + require('../../../../pages/workspace/accounting/intacct/export/SageIntacctDefaultVendorPage').default, + [SCREENS.WORKSPACE.ACCOUNTING.SAGE_INTACCT_NON_REIMBURSABLE_CREDIT_CARD_ACCOUNT]: () => + require('../../../../pages/workspace/accounting/intacct/export/SageIntacctNonReimbursableCreditCardAccountPage').default, + [SCREENS.WORKSPACE.ACCOUNTING.SAGE_INTACCT_ADVANCED]: () => require('../../../../pages/workspace/accounting/intacct/advanced/SageIntacctAdvancedPage').default, + [SCREENS.WORKSPACE.ACCOUNTING.SAGE_INTACCT_PAYMENT_ACCOUNT]: () => + require('../../../../pages/workspace/accounting/intacct/advanced/SageIntacctPaymentAccountPage').default, [SCREENS.WORKSPACE.WORKFLOWS_AUTO_REPORTING_FREQUENCY]: () => require('../../../../pages/workspace/workflows/WorkspaceAutoReportingFrequencyPage').default, [SCREENS.WORKSPACE.WORKFLOWS_AUTO_REPORTING_MONTHLY_OFFSET]: () => require('../../../../pages/workspace/workflows/WorkspaceAutoReportingMonthlyOffsetPage').default, [SCREENS.WORKSPACE.TAX_EDIT]: () => require('../../../../pages/workspace/taxes/WorkspaceEditTaxPage').default, @@ -360,6 +398,13 @@ 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/CreateReportFieldPage').default, + [SCREENS.WORKSPACE.REPORT_FIELD_SETTINGS]: () => require('../../../../pages/workspace/reportFields/ReportFieldSettingsPage').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/ReportFieldValueSettingsPage').default, + [SCREENS.WORKSPACE.REPORT_FIELDS_EDIT_INITIAL_VALUE]: () => require('../../../../pages/workspace/reportFields/ReportFieldInitialValuePage').default, + [SCREENS.WORKSPACE.REPORT_FIELDS_EDIT_VALUE]: () => require('../../../../pages/workspace/reportFields/ReportFieldEditValuePage').default, }); const EnablePaymentsStackNavigator = createModalStackNavigator({ @@ -404,6 +449,13 @@ const ProcessMoneyRequestHoldStackNavigator = createModalStackNavigator({ const TransactionDuplicateStackNavigator = createModalStackNavigator({ [SCREENS.TRANSACTION_DUPLICATE.REVIEW]: () => require('../../../../pages/TransactionDuplicate/Review').default, + [SCREENS.TRANSACTION_DUPLICATE.MERCHANT]: () => require('../../../../pages/TransactionDuplicate/ReviewMerchant').default, + [SCREENS.TRANSACTION_DUPLICATE.CATEGORY]: () => require('../../../../pages/TransactionDuplicate/ReviewCategory').default, + [SCREENS.TRANSACTION_DUPLICATE.TAG]: () => require('../../../../pages/TransactionDuplicate/ReviewTag').default, + [SCREENS.TRANSACTION_DUPLICATE.DESCRIPTION]: () => require('../../../../pages/TransactionDuplicate/ReviewDescription').default, + [SCREENS.TRANSACTION_DUPLICATE.TAX_CODE]: () => require('../../../../pages/TransactionDuplicate/ReviewTaxCode').default, + [SCREENS.TRANSACTION_DUPLICATE.BILLABLE]: () => require('../../../../pages/TransactionDuplicate/ReviewBillable').default, + [SCREENS.TRANSACTION_DUPLICATE.REIMBURSABLE]: () => require('../../../../pages/TransactionDuplicate/ReviewReimbursable').default, }); const SearchReportModalStackNavigator = createModalStackNavigator({ diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/useModalScreenOptions.ts b/src/libs/Navigation/AppNavigator/ModalStackNavigators/useModalScreenOptions.ts index 3c5ef1833835..460b0c732797 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/useModalScreenOptions.ts +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/useModalScreenOptions.ts @@ -1,19 +1,32 @@ -import type {StackNavigationOptions} from '@react-navigation/stack'; +import type {StackCardInterpolationProps, StackNavigationOptions} from '@react-navigation/stack'; import {CardStyleInterpolators} from '@react-navigation/stack'; import {useMemo} from 'react'; +import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; +import useWindowDimensions from '@hooks/useWindowDimensions'; +import {isSafari} from '@libs/Browser'; +import createModalCardStyleInterpolator from '@navigation/AppNavigator/createModalCardStyleInterpolator'; import type {ThemeStyles} from '@src/styles'; function useModalScreenOptions(getScreenOptions?: (styles: ThemeStyles) => StackNavigationOptions) { const styles = useThemeStyles(); + const styleUtils = useStyleUtils(); + const {isSmallScreenWidth} = useWindowDimensions(); + + let cardStyleInterpolator = CardStyleInterpolators.forHorizontalIOS; + + if (isSafari()) { + const customInterpolator = createModalCardStyleInterpolator(styleUtils); + cardStyleInterpolator = (props: StackCardInterpolationProps) => customInterpolator(isSmallScreenWidth, false, false, props); + } const defaultSubRouteOptions = useMemo( (): StackNavigationOptions => ({ cardStyle: styles.navigationScreenCardStyle, headerShown: false, - cardStyleInterpolator: CardStyleInterpolators.forHorizontalIOS, + cardStyleInterpolator, }), - [styles], + [styles, cardStyleInterpolator], ); return getScreenOptions?.(styles) ?? defaultSubRouteOptions; diff --git a/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx index 05c23797fe0e..6b83c1997693 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx @@ -1,12 +1,15 @@ -import type {StackScreenProps} from '@react-navigation/stack'; +import type {StackCardInterpolationProps, StackScreenProps} from '@react-navigation/stack'; import {createStackNavigator} from '@react-navigation/stack'; import React, {useMemo, useRef} from 'react'; import {View} from 'react-native'; import NoDropZone from '@components/DragAndDrop/NoDropZone'; +import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; +import {isSafari} from '@libs/Browser'; import ModalNavigatorScreenOptions from '@libs/Navigation/AppNavigator/ModalNavigatorScreenOptions'; import * as ModalStackNavigators from '@libs/Navigation/AppNavigator/ModalStackNavigators'; +import createModalCardStyleInterpolator from '@navigation/AppNavigator/createModalCardStyleInterpolator'; import type {AuthScreensParamList, RightModalNavigatorParamList} from '@navigation/types'; import NAVIGATORS from '@src/NAVIGATORS'; import SCREENS from '@src/SCREENS'; @@ -18,9 +21,19 @@ const Stack = createStackNavigator(); function RightModalNavigator({navigation}: RightModalNavigatorProps) { const styles = useThemeStyles(); + const styleUtils = useStyleUtils(); const {isSmallScreenWidth} = useWindowDimensions(); - const screenOptions = useMemo(() => ModalNavigatorScreenOptions(styles), [styles]); const isExecutingRef = useRef(false); + const screenOptions = useMemo(() => { + const options = ModalNavigatorScreenOptions(styles); + // The .forHorizontalIOS interpolator from `@react-navigation` is misbehaving on Safari, so we override it with Expensify custom interpolator + if (isSafari()) { + const customInterpolator = createModalCardStyleInterpolator(styleUtils); + options.cardStyleInterpolator = (props: StackCardInterpolationProps) => customInterpolator(isSmallScreenWidth, false, false, props); + } + + return options; + }, [isSmallScreenWidth, styleUtils, styles]); return ( diff --git a/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/BottomTabBar/index.tsx b/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/BottomTabBar/index.tsx index 2474c4f35a61..f244342c28ae 100644 --- a/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/BottomTabBar/index.tsx +++ b/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/BottomTabBar/index.tsx @@ -17,7 +17,7 @@ import getTopmostBottomTabRoute from '@libs/Navigation/getTopmostBottomTabRoute' import getTopmostCentralPaneRoute from '@libs/Navigation/getTopmostCentralPaneRoute'; import Navigation from '@libs/Navigation/Navigation'; import type {RootStackParamList, State} from '@libs/Navigation/types'; -import isCentralPaneName from '@libs/NavigationUtils'; +import {isCentralPaneName} from '@libs/NavigationUtils'; import {getChatTabBrickRoad} from '@libs/WorkspacesSettingsUtils'; import BottomTabAvatar from '@pages/home/sidebar/BottomTabAvatar'; import BottomTabBarFloatingActionButton from '@pages/home/sidebar/BottomTabBarFloatingActionButton'; @@ -58,7 +58,7 @@ function BottomTabBar({isLoadingApp = false}: PurposeForUsingExpensifyModalProps } Welcome.isOnboardingFlowCompleted({onNotCompleted: () => Navigation.navigate(ROUTES.ONBOARDING_ROOT)}); - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps }, [isLoadingApp]); // Parent navigator of the bottom tab bar is the root navigator. diff --git a/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/BottomTabBar/index.website.tsx b/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/BottomTabBar/index.website.tsx index ecacedde16dd..8c531a918af8 100644 --- a/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/BottomTabBar/index.website.tsx +++ b/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/BottomTabBar/index.website.tsx @@ -17,7 +17,7 @@ import getTopmostBottomTabRoute from '@libs/Navigation/getTopmostBottomTabRoute' import getTopmostCentralPaneRoute from '@libs/Navigation/getTopmostCentralPaneRoute'; import Navigation from '@libs/Navigation/Navigation'; import type {RootStackParamList, State} from '@libs/Navigation/types'; -import isCentralPaneName from '@libs/NavigationUtils'; +import {isCentralPaneName} from '@libs/NavigationUtils'; import {getChatTabBrickRoad} from '@libs/WorkspacesSettingsUtils'; import BottomTabAvatar from '@pages/home/sidebar/BottomTabAvatar'; import BottomTabBarFloatingActionButton from '@pages/home/sidebar/BottomTabBarFloatingActionButton'; @@ -54,7 +54,7 @@ function BottomTabBar({isLoadingApp = false}: PurposeForUsingExpensifyModalProps } Welcome.isOnboardingFlowCompleted({onNotCompleted: () => Navigation.navigate(ROUTES.ONBOARDING_ROOT)}); - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps }, [isLoadingApp]); // Parent navigator of the bottom tab bar is the root navigator. diff --git a/src/libs/Navigation/AppNavigator/createCustomFullScreenNavigator/index.tsx b/src/libs/Navigation/AppNavigator/createCustomFullScreenNavigator/index.tsx index f35c609402b0..7b928e4cf4df 100644 --- a/src/libs/Navigation/AppNavigator/createCustomFullScreenNavigator/index.tsx +++ b/src/libs/Navigation/AppNavigator/createCustomFullScreenNavigator/index.tsx @@ -29,7 +29,7 @@ function CustomFullScreenNavigator(props: FullScreenNavigatorProps) { } // We need to separately reset state of this navigator to trigger getRehydratedState. navigation.reset(navigation.getState()); - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps }, [isSmallScreenWidth]); return ( diff --git a/src/libs/Navigation/AppNavigator/createCustomStackNavigator/CustomRouter.ts b/src/libs/Navigation/AppNavigator/createCustomStackNavigator/CustomRouter.ts index fa7e8a55d1fc..a1768df5e0d6 100644 --- a/src/libs/Navigation/AppNavigator/createCustomStackNavigator/CustomRouter.ts +++ b/src/libs/Navigation/AppNavigator/createCustomStackNavigator/CustomRouter.ts @@ -7,7 +7,7 @@ import getTopmostCentralPaneRoute from '@libs/Navigation/getTopmostCentralPaneRo import linkingConfig from '@libs/Navigation/linkingConfig'; import getAdaptedStateFromPath from '@libs/Navigation/linkingConfig/getAdaptedStateFromPath'; import type {NavigationPartialRoute, RootStackParamList, State} from '@libs/Navigation/types'; -import isCentralPaneName from '@libs/NavigationUtils'; +import {isCentralPaneName} from '@libs/NavigationUtils'; import NAVIGATORS from '@src/NAVIGATORS'; import SCREENS from '@src/SCREENS'; import type {ResponsiveStackNavigatorRouterOptions} from './types'; diff --git a/src/libs/Navigation/AppNavigator/createCustomStackNavigator/index.tsx b/src/libs/Navigation/AppNavigator/createCustomStackNavigator/index.tsx index 84123dbfa569..310766f80e9d 100644 --- a/src/libs/Navigation/AppNavigator/createCustomStackNavigator/index.tsx +++ b/src/libs/Navigation/AppNavigator/createCustomStackNavigator/index.tsx @@ -9,7 +9,7 @@ import useWindowDimensions from '@hooks/useWindowDimensions'; import getTopmostCentralPaneRoute from '@libs/Navigation/getTopmostCentralPaneRoute'; import navigationRef from '@libs/Navigation/navigationRef'; import type {RootStackParamList, State} from '@libs/Navigation/types'; -import isCentralPaneName from '@libs/NavigationUtils'; +import {isCentralPaneName} from '@libs/NavigationUtils'; import SCREENS from '@src/SCREENS'; import CustomRouter from './CustomRouter'; import type {ResponsiveStackNavigatorProps, ResponsiveStackNavigatorRouterOptions} from './types'; diff --git a/src/libs/Navigation/FreezeWrapper.tsx b/src/libs/Navigation/FreezeWrapper.tsx index 23513dd87431..fb5f769b19c1 100644 --- a/src/libs/Navigation/FreezeWrapper.tsx +++ b/src/libs/Navigation/FreezeWrapper.tsx @@ -20,7 +20,7 @@ function FreezeWrapper({keepVisible = false, children}: FreezeWrapperProps) { useEffect(() => { const index = navigation.getState()?.routes.findIndex((route) => route.key === currentRoute.key) ?? 0; screenIndexRef.current = index; - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps }, []); useEffect(() => { diff --git a/src/libs/Navigation/Navigation.ts b/src/libs/Navigation/Navigation.ts index 5a7182405681..15d4ac6e4b31 100644 --- a/src/libs/Navigation/Navigation.ts +++ b/src/libs/Navigation/Navigation.ts @@ -1,10 +1,10 @@ import {findFocusedRoute} from '@react-navigation/core'; import type {EventArg, NavigationContainerEventMap} from '@react-navigation/native'; import {CommonActions, getPathFromState, StackActions} from '@react-navigation/native'; -import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; -import Onyx from 'react-native-onyx'; +import type {OnyxEntry} from 'react-native-onyx'; import Log from '@libs/Log'; -import isCentralPaneName from '@libs/NavigationUtils'; +import {isCentralPaneName, removePolicyIDParamFromState} from '@libs/NavigationUtils'; +import * as ReportConnection from '@libs/ReportConnection'; import * as ReportUtils from '@libs/ReportUtils'; import CONST from '@src/CONST'; import NAVIGATORS from '@src/NAVIGATORS'; @@ -35,13 +35,6 @@ let pendingRoute: Route | null = null; let shouldPopAllStateOnUP = false; -let allReports: OnyxCollection; -Onyx.connect({ - key: ONYXKEYS.COLLECTION.REPORT, - waitForCollectionCallback: true, - callback: (value) => (allReports = value), -}); - /** * Inform the navigation that next time user presses UP we should pop all the state back to LHN. */ @@ -69,7 +62,7 @@ const dismissModal = (reportID?: string, ref = navigationRef) => { originalDismissModal(ref); return; } - const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; + const report = ReportConnection.getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; originalDismissModalWithReport({reportID, ...report}, ref); }; // Re-exporting the closeRHPFlow here to fill in default value for navigationRef. The closeRHPFlow isn't defined in this file to avoid cyclic dependencies. @@ -129,7 +122,9 @@ function getDistanceFromPathInRootNavigator(path?: string): number { break; } - const pathFromState = getPathFromState(currentState, linkingConfig.config); + // When comparing path and pathFromState, the policyID parameter isn't included in the comparison + const currentStateWithoutPolicyID = removePolicyIDParamFromState(currentState as State); + const pathFromState = getPathFromState(currentStateWithoutPolicyID, linkingConfig.config); if (path === pathFromState.substring(1)) { return index; } diff --git a/src/libs/Navigation/NavigationRoot.tsx b/src/libs/Navigation/NavigationRoot.tsx index dd3a2890d0ec..db64aea7ffe8 100644 --- a/src/libs/Navigation/NavigationRoot.tsx +++ b/src/libs/Navigation/NavigationRoot.tsx @@ -93,7 +93,7 @@ function NavigationRoot({authenticated, lastVisitedPath, initialUrl, onReady}: N const {adaptedState} = getAdaptedStateFromPath(lastVisitedPath, linkingConfig.config); return adaptedState; }, - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps [], ); diff --git a/src/libs/Navigation/dismissModalWithReport.ts b/src/libs/Navigation/dismissModalWithReport.ts index 1bb939f5230f..1579a0565726 100644 --- a/src/libs/Navigation/dismissModalWithReport.ts +++ b/src/libs/Navigation/dismissModalWithReport.ts @@ -4,7 +4,7 @@ import {StackActions} from '@react-navigation/native'; import {findLastIndex} from 'lodash'; import type {OnyxEntry} from 'react-native-onyx'; import Log from '@libs/Log'; -import isCentralPaneName from '@libs/NavigationUtils'; +import {isCentralPaneName} from '@libs/NavigationUtils'; import getPolicyEmployeeAccountIDs from '@libs/PolicyEmployeeListUtils'; import {doesReportBelongToWorkspace} from '@libs/ReportUtils'; import NAVIGATORS from '@src/NAVIGATORS'; diff --git a/src/libs/Navigation/getTopmostCentralPaneRoute.ts b/src/libs/Navigation/getTopmostCentralPaneRoute.ts index 977f23cd3cd7..5ac72281eaf6 100644 --- a/src/libs/Navigation/getTopmostCentralPaneRoute.ts +++ b/src/libs/Navigation/getTopmostCentralPaneRoute.ts @@ -1,4 +1,4 @@ -import isCentralPaneName from '@libs/NavigationUtils'; +import {isCentralPaneName} from '@libs/NavigationUtils'; import type {CentralPaneName, NavigationPartialRoute, RootStackParamList, State} from './types'; // Get the name of topmost central pane route in the navigation stack. diff --git a/src/libs/Navigation/getTopmostReportActionID.ts b/src/libs/Navigation/getTopmostReportActionID.ts index ade982c87b7d..d3c6e41887d8 100644 --- a/src/libs/Navigation/getTopmostReportActionID.ts +++ b/src/libs/Navigation/getTopmostReportActionID.ts @@ -1,5 +1,5 @@ import type {NavigationState, PartialState} from '@react-navigation/native'; -import isCentralPaneName from '@libs/NavigationUtils'; +import {isCentralPaneName} from '@libs/NavigationUtils'; import SCREENS from '@src/SCREENS'; import type {RootStackParamList} from './types'; diff --git a/src/libs/Navigation/getTopmostReportId.ts b/src/libs/Navigation/getTopmostReportId.ts index 19bf24f1ba74..dc53d040f087 100644 --- a/src/libs/Navigation/getTopmostReportId.ts +++ b/src/libs/Navigation/getTopmostReportId.ts @@ -1,5 +1,5 @@ import type {NavigationState, PartialState} from '@react-navigation/native'; -import isCentralPaneName from '@libs/NavigationUtils'; +import {isCentralPaneName} from '@libs/NavigationUtils'; import SCREENS from '@src/SCREENS'; import type {RootStackParamList} from './types'; diff --git a/src/libs/Navigation/linkTo/getActionForBottomTabNavigator.ts b/src/libs/Navigation/linkTo/getActionForBottomTabNavigator.ts index 8af683e273d6..85580d068ad7 100644 --- a/src/libs/Navigation/linkTo/getActionForBottomTabNavigator.ts +++ b/src/libs/Navigation/linkTo/getActionForBottomTabNavigator.ts @@ -13,7 +13,6 @@ function getActionForBottomTabNavigator( shouldNavigate?: boolean, ): Writable | undefined { const bottomTabNavigatorRoute = state.routes.at(0); - if (!bottomTabNavigatorRoute || bottomTabNavigatorRoute.state === undefined || !action || action.type !== CONST.NAVIGATION.ACTION_TYPE.NAVIGATE) { return; } @@ -22,10 +21,10 @@ function getActionForBottomTabNavigator( let payloadParams = params.params as Record; const screen = params.screen; - if (!payloadParams) { - payloadParams = {policyID}; - } else if (!('policyID' in payloadParams && !!payloadParams?.policyID)) { + if (policyID && !payloadParams?.policyID) { payloadParams = {...payloadParams, policyID}; + } else if (!policyID) { + delete payloadParams?.policyID; } // Check if the current bottom tab is the same as the one we want to navigate to. If it is, we don't need to do anything. diff --git a/src/libs/Navigation/linkTo/index.ts b/src/libs/Navigation/linkTo/index.ts index 3c4608d6b5de..d8312937ed6f 100644 --- a/src/libs/Navigation/linkTo/index.ts +++ b/src/libs/Navigation/linkTo/index.ts @@ -4,7 +4,7 @@ import {findFocusedRoute} from '@react-navigation/native'; import {omitBy} from 'lodash'; import getIsNarrowLayout from '@libs/getIsNarrowLayout'; import extractPolicyIDsFromState from '@libs/Navigation/linkingConfig/extractPolicyIDsFromState'; -import isCentralPaneName from '@libs/NavigationUtils'; +import {isCentralPaneName} from '@libs/NavigationUtils'; import shallowCompare from '@libs/ObjectUtils'; import {extractPolicyIDFromPath, getPathWithoutPolicyID} from '@libs/PolicyUtils'; import getActionsFromPartialDiff from '@navigation/AppNavigator/getActionsFromPartialDiff'; @@ -72,7 +72,8 @@ export default function linkTo(navigation: NavigationContainerRef> = { - [SCREENS.WORKSPACE.PROFILE]: [SCREENS.WORKSPACE.NAME, SCREENS.WORKSPACE.ADDRESS, SCREENS.WORKSPACE.CURRENCY, SCREENS.WORKSPACE.DESCRIPTION, SCREENS.WORKSPACE.SHARE], + [SCREENS.WORKSPACE.PROFILE]: [ + SCREENS.WORKSPACE.NAME, + SCREENS.WORKSPACE.ADDRESS, + SCREENS.WORKSPACE.CURRENCY, + SCREENS.WORKSPACE.DESCRIPTION, + SCREENS.WORKSPACE.SHARE, + SCREENS.WORKSPACE.UPGRADE, + ], [SCREENS.WORKSPACE.REIMBURSE]: [SCREENS.WORKSPACE.RATE_AND_UNIT, SCREENS.WORKSPACE.RATE_AND_UNIT_RATE, SCREENS.WORKSPACE.RATE_AND_UNIT_UNIT], [SCREENS.WORKSPACE.MEMBERS]: [ SCREENS.WORKSPACE.INVITE, @@ -56,6 +63,11 @@ const FULL_SCREEN_TO_RHP_MAPPING: Partial> = { SCREENS.WORKSPACE.ACCOUNTING.XERO_BILL_PAYMENT_ACCOUNT_SELECTOR, SCREENS.WORKSPACE.ACCOUNTING.XERO_EXPORT_BANK_ACCOUNT_SELECT, SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_SUBSIDIARY_SELECTOR, + SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_TOKEN_INPUT, + SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_IMPORT, + SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_IMPORT_MAPPING, + SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_IMPORT_CUSTOMERS_OR_PROJECTS, + SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_IMPORT_CUSTOMERS_OR_PROJECTS_SELECT, SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_EXPORT, SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_PREFERRED_EXPORTER_SELECT, SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_DATE_SELECT, @@ -69,9 +81,26 @@ 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.NETSUITE_REIMBURSEMENT_ACCOUNT_SELECT, + SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_COLLECTION_ACCOUNT_SELECT, + SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_EXPENSE_REPORT_APPROVAL_LEVEL_SELECT, + SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_VENDOR_BILL_APPROVAL_LEVEL_SELECT, + SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_JOURNAL_ENTRY_APPROVAL_LEVEL_SELECT, + SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_APPROVAL_ACCOUNT_SELECT, + SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_CUSTOM_FORM_ID, SCREENS.WORKSPACE.ACCOUNTING.SAGE_INTACCT_PREREQUISITES, SCREENS.WORKSPACE.ACCOUNTING.ENTER_SAGE_INTACCT_CREDENTIALS, SCREENS.WORKSPACE.ACCOUNTING.EXISTING_SAGE_INTACCT_CONNECTIONS, + SCREENS.WORKSPACE.ACCOUNTING.SAGE_INTACCT_EXPORT, + SCREENS.WORKSPACE.ACCOUNTING.SAGE_INTACCT_PREFERRED_EXPORTER, + SCREENS.WORKSPACE.ACCOUNTING.SAGE_INTACCT_EXPORT_DATE, + SCREENS.WORKSPACE.ACCOUNTING.SAGE_INTACCT_REIMBURSABLE_EXPENSES, + SCREENS.WORKSPACE.ACCOUNTING.SAGE_INTACCT_NON_REIMBURSABLE_EXPENSES, + SCREENS.WORKSPACE.ACCOUNTING.SAGE_INTACCT_DEFAULT_VENDOR, + SCREENS.WORKSPACE.ACCOUNTING.SAGE_INTACCT_NON_REIMBURSABLE_CREDIT_CARD_ACCOUNT, + SCREENS.WORKSPACE.ACCOUNTING.SAGE_INTACCT_ADVANCED, + SCREENS.WORKSPACE.ACCOUNTING.SAGE_INTACCT_PAYMENT_ACCOUNT, ], [SCREENS.WORKSPACE.TAXES]: [ SCREENS.WORKSPACE.TAXES_SETTINGS, @@ -101,8 +130,16 @@ 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.EXPENSIFY_CARD]: [], + [SCREENS.WORKSPACE.REPORT_FIELDS]: [ + SCREENS.WORKSPACE.REPORT_FIELDS_CREATE, + SCREENS.WORKSPACE.REPORT_FIELD_SETTINGS, + 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.REPORT_FIELDS_EDIT_INITIAL_VALUE, + ], + [SCREENS.WORKSPACE.EXPENSIFY_CARD]: [SCREENS.WORKSPACE.EXPENSIFY_CARD_ISSUE_NEW], }; export default FULL_SCREEN_TO_RHP_MAPPING; diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts index 08274ab4c143..73d069eddb32 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -354,6 +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, }, @@ -393,9 +398,44 @@ 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.NETSUITE_REIMBURSEMENT_ACCOUNT_SELECT]: { + path: ROUTES.POLICY_ACCOUNTING_NETSUITE_REIMBURSEMENT_ACCOUNT_SELECT.route, + }, + [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_COLLECTION_ACCOUNT_SELECT]: { + path: ROUTES.POLICY_ACCOUNTING_NETSUITE_COLLECTION_ACCOUNT_SELECT.route, + }, + [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_EXPENSE_REPORT_APPROVAL_LEVEL_SELECT]: { + path: ROUTES.POLICY_ACCOUNTING_NETSUITE_EXPENSE_REPORT_APPROVAL_LEVEL_SELECT.route, + }, + [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_VENDOR_BILL_APPROVAL_LEVEL_SELECT]: { + path: ROUTES.POLICY_ACCOUNTING_NETSUITE_VENDOR_BILL_APPROVAL_LEVEL_SELECT.route, + }, + [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_JOURNAL_ENTRY_APPROVAL_LEVEL_SELECT]: { + path: ROUTES.POLICY_ACCOUNTING_NETSUITE_JOURNAL_ENTRY_APPROVAL_LEVEL_SELECT.route, + }, + [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_APPROVAL_ACCOUNT_SELECT]: { + path: ROUTES.POLICY_ACCOUNTING_NETSUITE_APPROVAL_ACCOUNT_SELECT.route, + }, + [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_CUSTOM_FORM_ID]: { + path: ROUTES.POLICY_ACCOUNTING_NETSUITE_CUSTOM_FORM_ID.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}, + [SCREENS.WORKSPACE.ACCOUNTING.SAGE_INTACCT_EXPORT]: {path: ROUTES.POLICY_ACCOUNTING_SAGE_INTACCT_EXPORT.route}, + [SCREENS.WORKSPACE.ACCOUNTING.SAGE_INTACCT_PREFERRED_EXPORTER]: {path: ROUTES.POLICY_ACCOUNTING_SAGE_INTACCT_PREFERRED_EXPORTER.route}, + [SCREENS.WORKSPACE.ACCOUNTING.SAGE_INTACCT_EXPORT_DATE]: {path: ROUTES.POLICY_ACCOUNTING_SAGE_INTACCT_EXPORT_DATE.route}, + [SCREENS.WORKSPACE.ACCOUNTING.SAGE_INTACCT_REIMBURSABLE_EXPENSES]: {path: ROUTES.POLICY_ACCOUNTING_SAGE_INTACCT_REIMBURSABLE_EXPENSES.route}, + [SCREENS.WORKSPACE.ACCOUNTING.SAGE_INTACCT_NON_REIMBURSABLE_EXPENSES]: {path: ROUTES.POLICY_ACCOUNTING_SAGE_INTACCT_NON_REIMBURSABLE_EXPENSES.route}, + [SCREENS.WORKSPACE.ACCOUNTING.SAGE_INTACCT_DEFAULT_VENDOR]: {path: ROUTES.POLICY_ACCOUNTING_SAGE_INTACCT_DEFAULT_VENDOR.route}, + [SCREENS.WORKSPACE.ACCOUNTING.SAGE_INTACCT_NON_REIMBURSABLE_CREDIT_CARD_ACCOUNT]: { + path: ROUTES.POLICY_ACCOUNTING_SAGE_INTACCT_NON_REIMBURSABLE_CREDIT_CARD_ACCOUNT.route, + }, + [SCREENS.WORKSPACE.ACCOUNTING.SAGE_INTACCT_ADVANCED]: {path: ROUTES.POLICY_ACCOUNTING_SAGE_INTACCT_ADVANCED.route}, + [SCREENS.WORKSPACE.ACCOUNTING.SAGE_INTACCT_PAYMENT_ACCOUNT]: {path: ROUTES.POLICY_ACCOUNTING_SAGE_INTACCT_PAYMENT_ACCOUNT.route}, [SCREENS.WORKSPACE.DESCRIPTION]: { path: ROUTES.WORKSPACE_PROFILE_DESCRIPTION.route, }, @@ -409,7 +449,7 @@ const config: LinkingOptions['config'] = { path: ROUTES.WORKSPACE_PROFILE_SHARE.route, }, [SCREENS.WORKSPACE.EXPENSIFY_CARD_ISSUE_NEW]: { - path: ROUTES.WORKSPACE_EXPENSIFY_CARD_ISSUE_NEW, + path: ROUTES.WORKSPACE_EXPENSIFY_CARD_ISSUE_NEW.route, }, [SCREENS.WORKSPACE.RATE_AND_UNIT]: { path: ROUTES.WORKSPACE_RATE_AND_UNIT.route, @@ -435,6 +475,12 @@ const config: LinkingOptions['config'] = { categoryName: (categoryName: string) => decodeURIComponent(categoryName), }, }, + [SCREENS.WORKSPACE.UPGRADE]: { + path: ROUTES.WORKSPACE_UPGRADE.route, + parse: { + featureName: (featureName: string) => decodeURIComponent(featureName), + }, + }, [SCREENS.WORKSPACE.CATEGORIES_SETTINGS]: { path: ROUTES.WORKSPACE_CATEGORIES_SETTINGS.route, }, @@ -524,6 +570,42 @@ 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, + parse: { + reportFieldID: (reportFieldID: string) => decodeURIComponent(reportFieldID), + }, + }, + [SCREENS.WORKSPACE.REPORT_FIELDS_ADD_VALUE]: { + path: ROUTES.WORKSPACE_REPORT_FIELD_ADD_VALUE.route, + parse: { + reportFieldID: (reportFieldID: string) => decodeURIComponent(reportFieldID), + }, + }, + [SCREENS.WORKSPACE.REPORT_FIELDS_VALUE_SETTINGS]: { + path: ROUTES.WORKSPACE_REPORT_FIELD_VALUE_SETTINGS.route, + parse: { + reportFieldID: (reportFieldID: string) => decodeURIComponent(reportFieldID), + }, + }, + [SCREENS.WORKSPACE.REPORT_FIELDS_EDIT_VALUE]: { + path: ROUTES.WORKSPACE_REPORT_FIELD_EDIT_VALUE.route, + }, + [SCREENS.WORKSPACE.REPORT_FIELD_SETTINGS]: { + path: ROUTES.WORKSPACE_REPORT_FIELD_SETTINGS.route, + parse: { + reportFieldID: (reportFieldID: string) => decodeURIComponent(reportFieldID), + }, + }, + [SCREENS.WORKSPACE.REPORT_FIELDS_EDIT_INITIAL_VALUE]: { + path: ROUTES.WORKSPACE_EDIT_REPORT_FIELD_INITIAL_VALUE.route, + parse: { + reportFieldID: (reportFieldID: string) => decodeURIComponent(reportFieldID), + }, + }, [SCREENS.REIMBURSEMENT_ACCOUNT]: { path: ROUTES.BANK_ACCOUNT_WITH_STEP_TO_OPEN.route, exact: true, @@ -746,6 +828,34 @@ const config: LinkingOptions['config'] = { path: ROUTES.TRANSACTION_DUPLICATE_REVIEW_PAGE.route, exact: true, }, + [SCREENS.TRANSACTION_DUPLICATE.MERCHANT]: { + path: ROUTES.TRANSACTION_DUPLICATE_REVIEW_MERCHANT_PAGE.route, + exact: true, + }, + [SCREENS.TRANSACTION_DUPLICATE.CATEGORY]: { + path: ROUTES.TRANSACTION_DUPLICATE_REVIEW_CATEGORY_PAGE.route, + exact: true, + }, + [SCREENS.TRANSACTION_DUPLICATE.TAG]: { + path: ROUTES.TRANSACTION_DUPLICATE_REVIEW_TAG_PAGE.route, + exact: true, + }, + [SCREENS.TRANSACTION_DUPLICATE.DESCRIPTION]: { + path: ROUTES.TRANSACTION_DUPLICATE_REVIEW_DESCRIPTION_PAGE.route, + exact: true, + }, + [SCREENS.TRANSACTION_DUPLICATE.TAX_CODE]: { + path: ROUTES.TRANSACTION_DUPLICATE_REVIEW_TAX_CODE_PAGE.route, + exact: true, + }, + [SCREENS.TRANSACTION_DUPLICATE.REIMBURSABLE]: { + path: ROUTES.TRANSACTION_DUPLICATE_REVIEW_REIMBURSABLE_PAGE.route, + exact: true, + }, + [SCREENS.TRANSACTION_DUPLICATE.BILLABLE]: { + path: ROUTES.TRANSACTION_DUPLICATE_REVIEW_BILLABLE_PAGE.route, + exact: true, + }, }, }, [SCREENS.RIGHT_MODAL.SPLIT_DETAILS]: { @@ -827,6 +937,9 @@ const config: LinkingOptions['config'] = { [SCREENS.WORKSPACE.CARD]: { path: ROUTES.WORKSPACE_CARD.route, }, + [SCREENS.WORKSPACE.EXPENSIFY_CARD]: { + path: ROUTES.WORKSPACE_EXPENSIFY_CARD.route, + }, [SCREENS.WORKSPACE.WORKFLOWS]: { path: ROUTES.WORKSPACE_WORKFLOWS.route, }, @@ -863,9 +976,6 @@ const config: LinkingOptions['config'] = { [SCREENS.WORKSPACE.REPORT_FIELDS]: { path: ROUTES.WORKSPACE_REPORT_FIELDS.route, }, - [SCREENS.WORKSPACE.EXPENSIFY_CARD]: { - path: ROUTES.WORKSPACE_EXPENSIFY_CARD.route, - }, [SCREENS.WORKSPACE.DISTANCE_RATES]: { path: ROUTES.WORKSPACE_DISTANCE_RATES.route, }, diff --git a/src/libs/Navigation/linkingConfig/customGetPathFromState.ts b/src/libs/Navigation/linkingConfig/customGetPathFromState.ts index 3ae1ed245ec6..a9c9b6f23b19 100644 --- a/src/libs/Navigation/linkingConfig/customGetPathFromState.ts +++ b/src/libs/Navigation/linkingConfig/customGetPathFromState.ts @@ -1,22 +1,13 @@ import {getPathFromState} from '@react-navigation/native'; -import _ from 'lodash'; import getPolicyIDFromState from '@libs/Navigation/getPolicyIDFromState'; import getTopmostBottomTabRoute from '@libs/Navigation/getTopmostBottomTabRoute'; import type {BottomTabName, RootStackParamList, State} from '@libs/Navigation/types'; +import {removePolicyIDParamFromState} from '@libs/NavigationUtils'; import SCREENS from '@src/SCREENS'; // The policy ID parameter should be included in the URL when any of these pages is opened in the bottom tab. const SCREENS_WITH_POLICY_ID_IN_URL: BottomTabName[] = [SCREENS.HOME] as const; -const removePolicyIDParamFromState = (state: State) => { - const stateCopy = _.cloneDeep(state); - const bottomTabRoute = getTopmostBottomTabRoute(stateCopy); - if (bottomTabRoute?.params && 'policyID' in bottomTabRoute.params) { - delete bottomTabRoute.params.policyID; - } - return stateCopy; -}; - const customGetPathFromState: typeof getPathFromState = (state, options) => { // For the Home and Settings pages we should remove policyID from the params, because on small screens it's displayed twice in the URL const stateWithoutPolicyID = removePolicyIDParamFromState(state as State); diff --git a/src/libs/Navigation/linkingConfig/getAdaptedStateFromPath.ts b/src/libs/Navigation/linkingConfig/getAdaptedStateFromPath.ts index 17ea0e17d1b9..2b057bf5edaa 100644 --- a/src/libs/Navigation/linkingConfig/getAdaptedStateFromPath.ts +++ b/src/libs/Navigation/linkingConfig/getAdaptedStateFromPath.ts @@ -4,7 +4,7 @@ import type {TupleToUnion} from 'type-fest'; import {isAnonymousUser} from '@libs/actions/Session'; import getIsNarrowLayout from '@libs/getIsNarrowLayout'; import type {BottomTabName, CentralPaneName, FullScreenName, NavigationPartialRoute, RootStackParamList} from '@libs/Navigation/types'; -import isCentralPaneName from '@libs/NavigationUtils'; +import {isCentralPaneName} from '@libs/NavigationUtils'; import {extractPolicyIDFromPath, getPathWithoutPolicyID} from '@libs/PolicyUtils'; import CONST from '@src/CONST'; import NAVIGATORS from '@src/NAVIGATORS'; diff --git a/src/libs/Navigation/linkingConfig/getMatchingBottomTabRouteForState.ts b/src/libs/Navigation/linkingConfig/getMatchingBottomTabRouteForState.ts index 4b4ed25959f0..67d76de4932d 100644 --- a/src/libs/Navigation/linkingConfig/getMatchingBottomTabRouteForState.ts +++ b/src/libs/Navigation/linkingConfig/getMatchingBottomTabRouteForState.ts @@ -23,7 +23,7 @@ function getMatchingBottomTabRouteForState(state: State, pol const tabName = CENTRAL_PANE_TO_TAB_MAPPING[topmostCentralPaneRoute.name]; if (tabName === SCREENS.SEARCH.BOTTOM_TAB) { - const topmostCentralPaneRouteParams = topmostCentralPaneRoute.params as Record; + const topmostCentralPaneRouteParams = {...topmostCentralPaneRoute.params} as Record; delete topmostCentralPaneRouteParams?.policyIDs; if (policyID) { topmostCentralPaneRouteParams.policyID = policyID; 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 59461bfc3c8f..19626a400b9d 100644 --- a/src/libs/Navigation/switchPolicyID.ts +++ b/src/libs/Navigation/switchPolicyID.ts @@ -3,7 +3,7 @@ import type {NavigationAction, NavigationContainerRef, NavigationState, PartialS import {getPathFromState} from '@react-navigation/native'; import type {Writable} from 'type-fest'; import getIsNarrowLayout from '@libs/getIsNarrowLayout'; -import isCentralPaneName from '@libs/NavigationUtils'; +import {isCentralPaneName} from '@libs/NavigationUtils'; import CONST from '@src/CONST'; import type {Route} from '@src/ROUTES'; import ROUTES from '@src/ROUTES'; @@ -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 26c14a50ec0a..274c50d38b02 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -10,7 +10,7 @@ import type { PartialState, Route, } from '@react-navigation/native'; -import type {ValueOf} from 'type-fest'; +import type {TupleToUnion, ValueOf} from 'type-fest'; import type {IOURequestType} from '@libs/actions/IOU'; import type {SearchColumnType, SortOrder} from '@libs/SearchUtils'; import type CONST from '@src/CONST'; @@ -205,6 +205,11 @@ type SettingsNavigatorParamList = { categoryName: string; backTo?: Routes; }; + [SCREENS.WORKSPACE.UPGRADE]: { + policyID: string; + featureName: string; + backTo?: Routes; + }; [SCREENS.WORKSPACE.CATEGORIES_SETTINGS]: { policyID: string; backTo?: Routes; @@ -268,6 +273,34 @@ 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; + reportFieldID?: string; + }; + [SCREENS.WORKSPACE.REPORT_FIELDS_ADD_VALUE]: { + policyID: string; + reportFieldID?: string; + }; + [SCREENS.WORKSPACE.REPORT_FIELDS_VALUE_SETTINGS]: { + policyID: string; + valueIndex: number; + reportFieldID?: string; + }; + [SCREENS.WORKSPACE.REPORT_FIELDS_EDIT_VALUE]: { + policyID: string; + valueIndex: number; + }; + [SCREENS.WORKSPACE.REPORT_FIELD_SETTINGS]: { + policyID: string; + reportFieldID: string; + }; + [SCREENS.WORKSPACE.REPORT_FIELDS_EDIT_INITIAL_VALUE]: { + policyID: string; + reportFieldID: string; + }; [SCREENS.WORKSPACE.MEMBER_DETAILS]: { policyID: string; accountID: string; @@ -399,6 +432,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; }; @@ -443,6 +492,59 @@ type SettingsNavigatorParamList = { [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_PROVINCIAL_TAX_POSTING_ACCOUNT_SELECT]: { policyID: string; }; + [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_ADVANCED]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_REIMBURSEMENT_ACCOUNT_SELECT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_COLLECTION_ACCOUNT_SELECT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_EXPENSE_REPORT_APPROVAL_LEVEL_SELECT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_VENDOR_BILL_APPROVAL_LEVEL_SELECT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_JOURNAL_ENTRY_APPROVAL_LEVEL_SELECT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_APPROVAL_ACCOUNT_SELECT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_CUSTOM_FORM_ID]: { + policyID: string; + expenseType: ValueOf; + }; + [SCREENS.WORKSPACE.ACCOUNTING.SAGE_INTACCT_EXPORT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.SAGE_INTACCT_PREFERRED_EXPORTER]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.SAGE_INTACCT_EXPORT_DATE]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.SAGE_INTACCT_REIMBURSABLE_EXPENSES]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.SAGE_INTACCT_NON_REIMBURSABLE_EXPENSES]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.SAGE_INTACCT_DEFAULT_VENDOR]: { + policyID: string; + reimbursable: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.SAGE_INTACCT_NON_REIMBURSABLE_CREDIT_CARD_ACCOUNT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.SAGE_INTACCT_ADVANCED]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.SAGE_INTACCT_PAYMENT_ACCOUNT]: { + policyID: string; + }; [SCREENS.GET_ASSISTANCE]: { backTo: Routes; }; @@ -475,6 +577,9 @@ type SettingsNavigatorParamList = { policyID: string; taxID: string; }; + [SCREENS.WORKSPACE.EXPENSIFY_CARD_ISSUE_NEW]: { + policyID: string; + }; } & ReimbursementAccountNavigatorParamList; type NewChatNavigatorParamList = { @@ -798,6 +903,27 @@ type TransactionDuplicateNavigatorParamList = { [SCREENS.TRANSACTION_DUPLICATE.REVIEW]: { threadReportID: string; }; + [SCREENS.TRANSACTION_DUPLICATE.MERCHANT]: { + threadReportID: string; + }; + [SCREENS.TRANSACTION_DUPLICATE.CATEGORY]: { + threadReportID: string; + }; + [SCREENS.TRANSACTION_DUPLICATE.TAG]: { + threadReportID: string; + }; + [SCREENS.TRANSACTION_DUPLICATE.DESCRIPTION]: { + threadReportID: string; + }; + [SCREENS.TRANSACTION_DUPLICATE.TAX_CODE]: { + threadReportID: string; + }; + [SCREENS.TRANSACTION_DUPLICATE.BILLABLE]: { + threadReportID: string; + }; + [SCREENS.TRANSACTION_DUPLICATE.REIMBURSABLE]: { + threadReportID: string; + }; }; type LeftModalNavigatorParamList = { @@ -853,6 +979,9 @@ type FullScreenNavigatorParamList = { [SCREENS.WORKSPACE.CARD]: { policyID: string; }; + [SCREENS.WORKSPACE.EXPENSIFY_CARD]: { + policyID: string; + }; [SCREENS.WORKSPACE.WORKFLOWS]: { policyID: string; }; @@ -902,7 +1031,6 @@ type FullScreenNavigatorParamList = { [SCREENS.WORKSPACE.DISTANCE_RATES]: { policyID: string; }; - [SCREENS.WORKSPACE.ACCOUNTING.ROOT]: { policyID: string; }; @@ -915,6 +1043,9 @@ type FullScreenNavigatorParamList = { [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_ONLINE_INVOICE_ACCOUNT_SELECTOR]: { policyID: string; }; + [SCREENS.WORKSPACE.EXPENSIFY_CARD]: { + policyID: string; + }; }; type OnboardingModalNavigatorParamList = { diff --git a/src/libs/NavigationUtils.ts b/src/libs/NavigationUtils.ts index 4fdc03c3d334..34fc0b971ef6 100644 --- a/src/libs/NavigationUtils.ts +++ b/src/libs/NavigationUtils.ts @@ -1,5 +1,7 @@ +import cloneDeep from 'lodash/cloneDeep'; import SCREENS from '@src/SCREENS'; -import type {CentralPaneName} from './Navigation/types'; +import getTopmostBottomTabRoute from './Navigation/getTopmostBottomTabRoute'; +import type {CentralPaneName, RootStackParamList, State} from './Navigation/types'; const CENTRAL_PANE_SCREEN_NAMES = new Set([ SCREENS.SETTINGS.WORKSPACES, @@ -23,4 +25,13 @@ function isCentralPaneName(screen: string | undefined): screen is CentralPaneNam return CENTRAL_PANE_SCREEN_NAMES.has(screen as CentralPaneName); } -export default isCentralPaneName; +const removePolicyIDParamFromState = (state: State) => { + const stateCopy = cloneDeep(state); + const bottomTabRoute = getTopmostBottomTabRoute(stateCopy); + if (bottomTabRoute?.params && 'policyID' in bottomTabRoute.params) { + delete bottomTabRoute.params.policyID; + } + return stateCopy; +}; + +export {isCentralPaneName, removePolicyIDParamFromState}; 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; -Onyx.connect({ - key: ONYXKEYS.COLLECTION.REPORT, - waitForCollectionCallback: true, - callback: (value) => (allReports = value), -}); - function getLastUpdateIDAppliedToClient(): Promise { return new Promise((resolve) => { Onyx.connect({ @@ -82,7 +75,7 @@ function navigateToReport({reportID, reportActionID}: ReportActionPushNotificati Log.info('[PushNotification] Navigating to report', false, {reportID, reportActionID}); const policyID = lastVisitedPath && extractPolicyIDFromPath(lastVisitedPath); - const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; + const report = ReportConnection.getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; const policyEmployeeAccountIDs = policyID ? getPolicyEmployeeAccountIDs(policyID) : []; const reportBelongsToWorkspace = policyID && !isEmptyObject(report) && doesReportBelongToWorkspace(report, policyEmployeeAccountIDs, policyID); diff --git a/src/libs/Notification/clearReportNotifications/index.native.ts b/src/libs/Notification/clearReportNotifications/index.native.ts index 3485df2d5ade..aabd24719ea8 100644 --- a/src/libs/Notification/clearReportNotifications/index.native.ts +++ b/src/libs/Notification/clearReportNotifications/index.native.ts @@ -8,7 +8,7 @@ import type ClearReportNotifications from './types'; const parseNotificationAndReportIDs = (pushPayload: PushPayload) => { 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/OnyxAwareParser.ts b/src/libs/OnyxAwareParser.ts deleted file mode 100644 index c058775341c2..000000000000 --- a/src/libs/OnyxAwareParser.ts +++ /dev/null @@ -1,52 +0,0 @@ -import {ExpensiMark} from 'expensify-common'; -import Onyx from 'react-native-onyx'; -import ONYXKEYS from '@src/ONYXKEYS'; - -const parser = new ExpensiMark(); - -const reportIDToNameMap: Record = {}; -const accountIDToNameMap: Record = {}; - -Onyx.connect({ - key: ONYXKEYS.COLLECTION.REPORT, - callback: (report) => { - if (!report) { - return; - } - - reportIDToNameMap[report.reportID] = report.reportName ?? report.displayName ?? report.reportID; - }, -}); - -Onyx.connect({ - key: ONYXKEYS.PERSONAL_DETAILS_LIST, - callback: (personalDetailsList) => { - Object.values(personalDetailsList ?? {}).forEach((personalDetails) => { - if (!personalDetails) { - return; - } - - accountIDToNameMap[personalDetails.accountID] = personalDetails.login ?? String(personalDetails.accountID); - }); - }, -}); - -function parseHtmlToMarkdown( - html: string, - reportIDToName?: Record, - accountIDToName?: Record, - cacheVideoAttributes?: (videoSource: string, videoAttrs: string) => void, -): string { - return parser.htmlToMarkdown(html, {reportIDToName: reportIDToName ?? reportIDToNameMap, accountIDToName: accountIDToName ?? accountIDToNameMap, cacheVideoAttributes}); -} - -function parseHtmlToText( - html: string, - reportIDToName?: Record, - accountIDToName?: Record, - cacheVideoAttributes?: (videoSource: string, videoAttrs: string) => void, -): string { - return parser.htmlToText(html, {reportIDToName: reportIDToName ?? reportIDToNameMap, accountIDToName: accountIDToName ?? accountIDToNameMap, cacheVideoAttributes}); -} - -export {parseHtmlToMarkdown, parseHtmlToText}; diff --git a/src/libs/OptionsListUtils.ts b/src/libs/OptionsListUtils.ts index 4ad79c30e86d..330d9d6ef61d 100644 --- a/src/libs/OptionsListUtils.ts +++ b/src/libs/OptionsListUtils.ts @@ -53,6 +53,7 @@ import * as PersonalDetailsUtils from './PersonalDetailsUtils'; import * as PhoneNumber from './PhoneNumber'; import * as PolicyUtils from './PolicyUtils'; import * as ReportActionUtils from './ReportActionsUtils'; +import * as ReportConnection from './ReportConnection'; import * as ReportUtils from './ReportUtils'; import * as TaskUtils from './TaskUtils'; import * as TransactionUtils from './TransactionUtils'; @@ -175,6 +176,7 @@ type GetOptionsConfig = { recentlyUsedPolicyReportFieldOptions?: string[]; transactionViolations?: OnyxCollection; includeInvoiceRooms?: boolean; + includeDomainEmail?: boolean; }; type GetUserToInviteConfig = { @@ -221,6 +223,10 @@ type FilterOptionsConfig = Pick< 'sortByReportTypeInSearch' | 'canInviteUser' | 'betas' | 'selectedOptions' | 'excludeUnknownUsers' | 'excludeLogins' | 'maxRecentReportsToShow' > & {preferChatroomsOverThreads?: boolean}; +type HasText = { + text?: string; +}; + /** * OptionsListUtils is used to build a list options passed to the OptionsList component. Several different UI views can * be configured to display different results based on the options passed to the private getOptions() method. Public @@ -338,13 +344,6 @@ Onyx.connect({ }, }); -let allReports: OnyxCollection = {}; -Onyx.connect({ - key: ONYXKEYS.COLLECTION.REPORT, - waitForCollectionCallback: true, - callback: (value) => (allReports = value), -}); - let allReportsDraft: OnyxCollection; Onyx.connect({ key: ONYXKEYS.COLLECTION.REPORT_DRAFT, @@ -356,6 +355,7 @@ Onyx.connect({ * Get the report or draft report given a reportID */ function getReportOrDraftReport(reportID: string | undefined): OnyxEntry { + const allReports = ReportConnection.getAllReports(); if (!allReports && !allReportsDraft) { return undefined; } @@ -991,7 +991,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". @@ -1009,7 +1009,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, @@ -1065,7 +1065,7 @@ function sortTags(tags: Record | Array | Category[], isOneLine = false, selectedOptionsName: string[] = []): OptionTree[] { +function getCategoryOptionTree(options: Record | Category[], isOneLine = false, selectedOptions: Category[] = []): OptionTree[] { const optionCollection = new Map(); Object.values(options).forEach((option) => { if (isOneLine) { @@ -1090,6 +1090,8 @@ function getCategoryOptionTree(options: Record | Category[], i const indents = times(index, () => CONST.INDENTS).join(''); const isChild = array.length - 1 === index; const searchText = array.slice(0, index + 1).join(CONST.PARENT_CHILD_SEPARATOR); + const selectedParentOption = !isChild && Object.values(selectedOptions).find((op) => op.name === searchText); + const isParentOptionDisabled = !selectedParentOption || !selectedParentOption.enabled || selectedParentOption.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; if (optionCollection.has(searchText)) { return; @@ -1100,8 +1102,8 @@ function getCategoryOptionTree(options: Record | Category[], i keyForList: searchText, searchText, tooltipText: optionName, - isDisabled: isChild ? !option.enabled || option.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE : !selectedOptionsName.includes(searchText), - isSelected: isChild ? !!option.isSelected : selectedOptionsName.includes(searchText), + isDisabled: isChild ? !option.enabled || option.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE : isParentOptionDisabled, + isSelected: isChild ? !!option.isSelected : !!selectedParentOption, pendingAction: option.pendingAction, }); }); @@ -1129,7 +1131,8 @@ function getCategoryListSections( selectedOptions.forEach((option) => { if (enabledCategoriesNames.includes(option.name)) { - selectedOptionsWithDisabledState.push({...option, isSelected: true, enabled: true}); + const categoryObj = enabledCategories.find((category) => category.name === option.name); + selectedOptionsWithDisabledState.push({...(categoryObj ?? option), isSelected: true, enabled: true}); return; } selectedOptionsWithDisabledState.push({...option, isSelected: true, enabled: false}); @@ -1189,7 +1192,7 @@ function getCategoryListSections( const filteredCategories = enabledCategories.filter((category) => !selectedOptionNames.includes(category.name)); if (numberOfEnabledCategories < CONST.CATEGORY_LIST_THRESHOLD) { - const data = getCategoryOptionTree(filteredCategories, false, selectedOptionNames); + const data = getCategoryOptionTree(filteredCategories, false, selectedOptionsWithDisabledState); categorySections.push({ // "All" section when items amount less than the threshold title: '', @@ -1224,7 +1227,7 @@ function getCategoryListSections( }); } - const data = getCategoryOptionTree(filteredCategories, false, selectedOptionNames); + const data = getCategoryOptionTree(filteredCategories, false, selectedOptionsWithDisabledState); categorySections.push({ // "All" section when items amount more than the threshold title: Localize.translateLocal('common.all'), @@ -1802,6 +1805,7 @@ function getOptions( policyReportFieldOptions = [], recentlyUsedPolicyReportFieldOptions = [], includeInvoiceRooms = false, + includeDomainEmail = false, }: GetOptionsConfig, ): Options { if (includeCategories) { @@ -1878,6 +1882,8 @@ function getOptions( isInFocusMode: false, excludeEmptyChats: false, includeSelfDM, + login: option.login, + includeDomainEmail, }); }); @@ -1951,7 +1957,9 @@ function getOptions( return option; }); - const havingLoginPersonalDetails = includeP2P ? options.personalDetails.filter((detail) => !!detail?.login && !!detail.accountID && !detail?.isOptimisticPersonalDetail) : []; + const havingLoginPersonalDetails = includeP2P + ? options.personalDetails.filter((detail) => !!detail?.login && !!detail.accountID && !detail?.isOptimisticPersonalDetail && (includeDomainEmail || !Str.isDomainEmail(detail.login))) + : []; let allPersonalDetailsOptions = havingLoginPersonalDetails; if (sortPersonalDetailsByAlphaAsc) { @@ -2486,11 +2494,6 @@ function filterOptions(options: Options, searchInputValue: string, config?: Filt values.push(item.login.replace(emailRegex, '')); } - if (!item.isChatRoom) { - const participantNames = getParticipantNames(item.participantsList ?? []); - values = values.concat(Array.from(participantNames)); - } - if (item.isThread) { if (item.alternateText) { values.push(item.alternateText); @@ -2500,7 +2503,11 @@ function filterOptions(options: Options, searchInputValue: string, config?: Filt if (item.subtitle) { values.push(item.subtitle); } - } else { + } + + if (!item.isChatRoom) { + const participantNames = getParticipantNames(item.participantsList ?? []); + values = values.concat(Array.from(participantNames)); values = values.concat(getParticipantsLoginsArray(item)); } @@ -2556,6 +2563,10 @@ function filterOptions(options: Options, searchInputValue: string, config?: Filt }; } +function sortItemsAlphabetically(membersList: T[]): T[] { + return membersList.sort((a, b) => (a.text ?? '').toLowerCase().localeCompare((b.text ?? '').toLowerCase())); +} + export { getAvatarsForAccountIDs, isCurrentUser, @@ -2581,6 +2592,7 @@ export { getEnabledCategoriesCount, hasEnabledOptions, sortCategories, + sortItemsAlphabetically, sortTags, getCategoryOptionTree, hasEnabledTags, 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/Parser.ts b/src/libs/Parser.ts new file mode 100644 index 000000000000..cbfeb6e40922 --- /dev/null +++ b/src/libs/Parser.ts @@ -0,0 +1,51 @@ +// eslint-disable-next-line no-restricted-imports +import {ExpensiMark} from 'expensify-common'; +import Onyx from 'react-native-onyx'; +import ONYXKEYS from '@src/ONYXKEYS'; +import Log from './Log'; +import * as ReportConnection from './ReportConnection'; + +const accountIDToNameMap: Record = {}; + +Onyx.connect({ + key: ONYXKEYS.PERSONAL_DETAILS_LIST, + callback: (personalDetailsList) => { + Object.values(personalDetailsList ?? {}).forEach((personalDetails) => { + if (!personalDetails) { + return; + } + + accountIDToNameMap[personalDetails.accountID] = personalDetails.login ?? String(personalDetails.accountID); + }); + }, +}); + +type Extras = { + reportIDToName?: Record; + accountIDToName?: Record; + cacheVideoAttributes?: (vidSource: string, attrs: string) => void; + videoAttributeCache?: Record; +}; + +class ExpensiMarkWithContext extends ExpensiMark { + htmlToMarkdown(htmlString: string, extras?: Extras): string { + return super.htmlToMarkdown(htmlString, { + reportIDToName: extras?.reportIDToName ?? ReportConnection.getAllReportsNameMap(), + accountIDToName: extras?.accountIDToName ?? accountIDToNameMap, + cacheVideoAttributes: extras?.cacheVideoAttributes, + }); + } + + htmlToText(htmlString: string, extras?: Extras): string { + return super.htmlToText(htmlString, { + reportIDToName: extras?.reportIDToName ?? ReportConnection.getAllReportsNameMap(), + accountIDToName: extras?.accountIDToName ?? accountIDToNameMap, + cacheVideoAttributes: extras?.cacheVideoAttributes, + }); + } +} + +ExpensiMarkWithContext.setLogger(Log); +const Parser = new ExpensiMarkWithContext(); + +export default Parser; diff --git a/src/libs/Permissions.ts b/src/libs/Permissions.ts index fb4c99b3d465..faea5965fee4 100644 --- a/src/libs/Permissions.ts +++ b/src/libs/Permissions.ts @@ -44,6 +44,10 @@ function canUseNetSuiteIntegration(betas: OnyxEntry): boolean { return !!betas?.includes(CONST.BETAS.NETSUITE_ON_NEW_EXPENSIFY) || canUseAllBetas(betas); } +function canUseSageIntacctIntegration(betas: OnyxEntry): boolean { + return !!betas?.includes(CONST.BETAS.INTACCT_ON_NEW_EXPENSIFY) || canUseAllBetas(betas); +} + function canUseReportFieldsFeature(betas: OnyxEntry): boolean { return !!betas?.includes(CONST.BETAS.REPORT_FIELDS_FEATURE) || canUseAllBetas(betas); } @@ -74,6 +78,7 @@ export default { canUseWorkflowsDelayedSubmission, canUseSpotnanaTravel, canUseNetSuiteIntegration, + canUseSageIntacctIntegration, canUseReportFieldsFeature, canUseWorkspaceFeeds, canUseNetSuiteUSATax, diff --git a/src/libs/PersonalDetailsUtils.ts b/src/libs/PersonalDetailsUtils.ts index 8ba468e87ed0..9beb3d382696 100644 --- a/src/libs/PersonalDetailsUtils.ts +++ b/src/libs/PersonalDetailsUtils.ts @@ -32,11 +32,16 @@ Onyx.connect({ }, }); +const hiddenTranslation = Localize.translateLocal('common.hidden'); +const youTranslation = Localize.translateLocal('common.you').toLowerCase(); + +const regexMergedAccount = new RegExp(CONST.REGEX.MERGED_ACCOUNT_PREFIX); + function getDisplayNameOrDefault(passedPersonalDetails?: Partial | null, defaultValue = '', shouldFallbackToHidden = true, shouldAddCurrentUserPostfix = false): string { let displayName = passedPersonalDetails?.displayName ?? ''; // If the displayName starts with the merged account prefix, remove it. - if (new RegExp(CONST.REGEX.MERGED_ACCOUNT_PREFIX).test(displayName)) { + if (regexMergedAccount.test(displayName)) { // Remove the merged account prefix from the displayName. displayName = displayName.replace(CONST.REGEX.MERGED_ACCOUNT_PREFIX, ''); } @@ -48,7 +53,7 @@ function getDisplayNameOrDefault(passedPersonalDetails?: Partial, 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 ); } @@ -158,7 +161,7 @@ const isPolicyAdmin = (policy: OnyxInputOrEntry, currentUserLogin?: stri (policy?.role ?? (currentUserLogin && policy?.employeeList?.[currentUserLogin]?.role)) === CONST.POLICY.ROLE.ADMIN; /** - * Checks if the current user is an user of the policy. + * Checks if the current user is of the role "user" on the policy. */ const isPolicyUser = (policy: OnyxInputOrEntry, currentUserLogin?: string): boolean => (policy?.role ?? (currentUserLogin && policy?.employeeList?.[currentUserLogin]?.role)) === CONST.POLICY.ROLE.USER; @@ -238,6 +241,7 @@ function getTagListName(policyTagList: OnyxEntry, orderWeight: nu return Object.values(policyTagList).find((tag) => tag.orderWeight === orderWeight)?.name ?? ''; } + /** * Gets all tag lists of a policy */ @@ -536,6 +540,74 @@ function canUseProvincialTaxNetSuite(subsidiaryCountry?: string) { return subsidiaryCountry === '_canada'; } +function getNetSuiteReimbursableAccountOptions(policy: Policy | undefined, selectedBankAccountId: string | undefined): SelectorType[] { + const payableAccounts = policy?.connections?.netsuite.options.data.payableList ?? []; + const accountOptions = (payableAccounts ?? []).filter(({type}) => type === CONST.NETSUITE_ACCOUNT_TYPE.BANK || type === CONST.NETSUITE_ACCOUNT_TYPE.CREDIT_CARD); + + return accountOptions.map(({id, name}) => ({ + value: id, + text: name, + keyForList: id, + isSelected: selectedBankAccountId === id, + })); +} + +function getNetSuiteCollectionAccountOptions(policy: Policy | undefined, selectedBankAccountId: string | undefined): SelectorType[] { + const payableAccounts = policy?.connections?.netsuite.options.data.payableList ?? []; + const accountOptions = (payableAccounts ?? []).filter(({type}) => type === CONST.NETSUITE_ACCOUNT_TYPE.BANK); + + return accountOptions.map(({id, name}) => ({ + value: id, + text: name, + keyForList: id, + isSelected: selectedBankAccountId === id, + })); +} + +function getNetSuiteApprovalAccountOptions(policy: Policy | undefined, selectedBankAccountId: string | undefined): SelectorType[] { + const payableAccounts = policy?.connections?.netsuite.options.data.payableList ?? []; + const defaultApprovalAccount: NetSuiteAccount = { + id: CONST.NETSUITE_APPROVAL_ACCOUNT_DEFAULT, + name: Localize.translateLocal('workspace.netsuite.advancedConfig.defaultApprovalAccount'), + type: CONST.NETSUITE_ACCOUNT_TYPE.ACCOUNTS_PAYABLE, + }; + const accountOptions = [defaultApprovalAccount].concat((payableAccounts ?? []).filter(({type}) => type === CONST.NETSUITE_ACCOUNT_TYPE.ACCOUNTS_PAYABLE)); + + return accountOptions.map(({id, name}) => ({ + value: id, + text: name, + keyForList: id, + isSelected: selectedBankAccountId === id, + })); +} + +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; @@ -546,6 +618,46 @@ function getIntegrationLastSuccessfulDate(connection?: Connections[keyof Connect return (connection as ConnectionWithLastSyncData)?.lastSync?.successfulDate; } +function getSageIntacctBankAccounts(policy?: Policy, selectedBankAccountId?: string): SelectorType[] { + const bankAccounts = policy?.connections?.intacct?.data?.bankAccounts ?? []; + return (bankAccounts ?? []).map(({id, name}) => ({ + value: id, + text: name, + keyForList: id, + isSelected: selectedBankAccountId === id, + })); +} + +function getSageIntacctVendors(policy?: Policy, selectedVendorId?: string): SelectorType[] { + const vendors = policy?.connections?.intacct?.data?.vendors ?? []; + return vendors.map(({id, value}) => ({ + value: id, + text: value, + keyForList: id, + isSelected: selectedVendorId === id, + })); +} + +function getSageIntacctNonReimbursableActiveDefaultVendor(policy?: Policy): string | undefined { + const { + nonReimbursableCreditCardChargeDefaultVendor: creditCardDefaultVendor, + nonReimbursableVendor: expenseReportDefaultVendor, + nonReimbursable, + } = policy?.connections?.intacct?.config.export ?? {}; + + return nonReimbursable === CONST.SAGE_INTACCT_NON_REIMBURSABLE_EXPENSE_TYPE.CREDIT_CARD_CHARGE ? creditCardDefaultVendor : expenseReportDefaultVendor; +} + +function getSageIntacctCreditCards(policy?: Policy, selectedAccount?: string): SelectorType[] { + const creditCards = policy?.connections?.intacct?.data?.creditCards ?? []; + return creditCards.map(({name}) => ({ + value: name, + text: name, + keyForList: name, + isSelected: name === selectedAccount, + })); +} + /** * Sort the workspaces by their name, while keeping the selected one at the beginning. * @param workspace1 Details of the first workspace to be compared. @@ -586,6 +698,13 @@ function getCurrentConnectionName(policy: Policy | undefined): string | undefine return connectionKey ? CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[connectionKey] : undefined; } +/** + * Check if the policy member is deleted from the workspace + */ +function isDeletedPolicyEmployee(policyEmployee: PolicyEmployee, isOffline: boolean) { + return !isOffline && policyEmployee.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && isEmptyObject(policyEmployee.errors); +} + export { canEditTaxRate, extractPolicyIDFromPath, @@ -619,6 +738,7 @@ export { hasPolicyErrorFields, hasTaxRateError, isExpensifyTeam, + isDeletedPolicyEmployee, isFreeGroupPolicy, isInstantSubmitEnabled, isPaidGroupPolicy, @@ -641,10 +761,17 @@ export { getNetSuiteVendorOptions, canUseTaxNetSuite, canUseProvincialTaxNetSuite, + getNetSuiteReimbursableAccountOptions, + getNetSuiteCollectionAccountOptions, + getNetSuiteApprovalAccountOptions, getNetSuitePayableAccountOptions, getNetSuiteReceivableAccountOptions, getNetSuiteInvoiceItemOptions, getNetSuiteTaxAccountOptions, + getSageIntacctVendors, + getSageIntacctNonReimbursableActiveDefaultVendor, + getSageIntacctCreditCards, + getSageIntacctBankAccounts, getCustomUnit, getCustomUnitRate, sortWorkspacesBySelected, @@ -652,6 +779,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/ReportActionItemEventHandler/index.android.ts b/src/libs/ReportActionItemEventHandler/index.android.ts new file mode 100644 index 000000000000..ba24fceb9899 --- /dev/null +++ b/src/libs/ReportActionItemEventHandler/index.android.ts @@ -0,0 +1,14 @@ +import {InteractionManager} from 'react-native'; +import type ReportActionItemEventHandler from './types'; + +const reportActionItemEventHandler: ReportActionItemEventHandler = { + handleComposerLayoutChange: (reportScrollManager, index) => () => { + InteractionManager.runAfterInteractions(() => { + requestAnimationFrame(() => { + reportScrollManager.scrollToIndex(index, true); + }); + }); + }, +}; + +export default reportActionItemEventHandler; diff --git a/src/libs/ReportActionItemEventHandler/index.ts b/src/libs/ReportActionItemEventHandler/index.ts new file mode 100644 index 000000000000..87d79a8d3ad0 --- /dev/null +++ b/src/libs/ReportActionItemEventHandler/index.ts @@ -0,0 +1,7 @@ +import type ReportActionItemEventHandler from './types'; + +const reportActionItemEventHandler: ReportActionItemEventHandler = { + handleComposerLayoutChange: () => () => {}, +}; + +export default reportActionItemEventHandler; diff --git a/src/libs/ReportActionItemEventHandler/types.ts b/src/libs/ReportActionItemEventHandler/types.ts new file mode 100644 index 000000000000..810c3ec02373 --- /dev/null +++ b/src/libs/ReportActionItemEventHandler/types.ts @@ -0,0 +1,8 @@ +import type {LayoutChangeEvent} from 'react-native'; +import type ReportScrollManagerData from '@hooks/useReportScrollManager/types'; + +type ReportActionItemEventHandler = { + handleComposerLayoutChange: (reportScrollManager: ReportScrollManagerData, index: number) => (event: LayoutChangeEvent) => void; +}; + +export default ReportActionItemEventHandler; diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts index c8599d785b22..3f8acd0e06fe 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -20,10 +20,12 @@ import isReportMessageAttachment from './isReportMessageAttachment'; import * as Localize from './Localize'; import Log from './Log'; import type {MessageElementBase, MessageTextElement} from './MessageElement'; -import {parseHtmlToText} from './OnyxAwareParser'; +import Parser from './Parser'; import * as PersonalDetailsUtils from './PersonalDetailsUtils'; +import * as ReportConnection from './ReportConnection'; import type {OptimisticIOUReportAction, PartialReportAction} from './ReportUtils'; import StringUtils from './StringUtils'; +// eslint-disable-next-line import/no-cycle import * as TransactionUtils from './TransactionUtils'; type LastVisibleMessage = { @@ -45,15 +47,6 @@ type MemberChangeMessageRoomReferenceElement = { type MemberChangeMessageElement = MessageTextElement | MemberChangeMessageUserMentionElement | MemberChangeMessageRoomReferenceElement; -let allReports: OnyxCollection = {}; -Onyx.connect({ - key: ONYXKEYS.COLLECTION.REPORT, - waitForCollectionCallback: true, - callback: (reports) => { - allReports = reports; - }, -}); - let allReportActions: OnyxCollection; Onyx.connect({ key: ONYXKEYS.COLLECTION.REPORT_ACTIONS, @@ -350,27 +343,12 @@ function getSortedReportActions(reportActions: ReportAction[] | null, shouldSort return sortedActions; } -function isOptimisticAction(reportAction: ReportAction) { - return ( - !!reportAction.isOptimisticAction || - reportAction.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD || - reportAction.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE - ); -} - -function shouldIgnoreGap(currentReportAction: ReportAction | undefined, nextReportAction: ReportAction | undefined) { - if (!currentReportAction || !nextReportAction) { - return false; - } - return ( - isOptimisticAction(currentReportAction) || - isOptimisticAction(nextReportAction) || - !!getWhisperedTo(currentReportAction).length || - !!getWhisperedTo(nextReportAction).length || - currentReportAction.actionName === CONST.REPORT.ACTIONS.TYPE.ROOM_CHANGE_LOG.INVITE_TO_ROOM || - nextReportAction.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED || - nextReportAction.actionName === CONST.REPORT.ACTIONS.TYPE.CLOSED - ); +/** + * Returns filtered list for one transaction view as we don't want to display IOU action type in the one-transaction view + * Separated it from getCombinedReportActions, so it can be reused + */ +function getFilteredForOneTransactionView(reportActions: ReportAction[]): ReportAction[] { + return reportActions.filter((action) => !isSentMoneyReportAction(action)); } /** @@ -392,7 +370,7 @@ function getCombinedReportActions( // Filter out request money actions because we don't want to show any preview actions for one transaction reports const filteredTransactionThreadReportActions = transactionThreadReportActions?.filter((action) => action.actionName !== CONST.REPORT.ACTIONS.TYPE.CREATED); - const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; + const report = ReportConnection.getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; const isSelfDM = report?.chatType === CONST.REPORT.CHAT_TYPE.SELF_DM; // Filter out request and send money request actions because we don't want to show any preview actions for one transaction reports const filteredReportActions = [...reportActions, ...filteredTransactionThreadReportActions].filter((action) => { @@ -409,51 +387,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. */ @@ -731,8 +664,13 @@ function replaceBaseURLInPolicyChangeLogAction(reportAction: ReportAction): Repo } function getLastVisibleAction(reportID: string, actionsToMerge: OnyxCollection | OnyxCollectionInputValue = {}): OnyxEntry { - const reportActions = Object.values(fastMerge(allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`] ?? {}, actionsToMerge ?? {}, true)); - const visibleReportActions = Object.values(reportActions ?? {}).filter((action): action is ReportAction => shouldReportActionBeVisibleAsLastAction(action)); + let reportActions: Array = []; + if (!_.isEmpty(actionsToMerge)) { + reportActions = Object.values(fastMerge(allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`] ?? {}, actionsToMerge ?? {}, true)); + } else { + reportActions = Object.values(allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`] ?? {}); + } + const visibleReportActions = reportActions.filter((action): action is ReportAction => shouldReportActionBeVisibleAsLastAction(action)); const sortedReportActions = getSortedReportActions(visibleReportActions, true); if (sortedReportActions.length === 0) { return undefined; @@ -894,7 +832,7 @@ function getMostRecentReportActionLastModified(): string { // We might not have actions so we also look at the report objects to see if any have a lastVisibleActionLastModified that is more recent. We don't need to get // any reports that have been updated before either a recently updated report or reportAction as we should be up to date on these - Object.values(allReports ?? {}).forEach((report) => { + Object.values(ReportConnection.getAllReports() ?? {}).forEach((report) => { const reportLastVisibleActionLastModified = report?.lastVisibleActionLastModified ?? report?.lastVisibleActionCreated; if (!reportLastVisibleActionLastModified || reportLastVisibleActionLastModified < mostRecentReportActionLastModified) { return; @@ -965,7 +903,7 @@ function isTaskAction(reportAction: OnyxEntry): boolean { */ function getOneTransactionThreadReportID(reportID: string, reportActions: OnyxEntry | ReportAction[], isOffline: boolean | undefined = undefined): string | undefined { // If the report is not an IOU, Expense report, or Invoice, it shouldn't be treated as one-transaction report. - const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; + const report = ReportConnection.getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; if (report?.type !== CONST.REPORT.TYPE.IOU && report?.type !== CONST.REPORT.TYPE.EXPENSE && report?.type !== CONST.REPORT.TYPE.INVOICE) { return; } @@ -1142,11 +1080,11 @@ function getReportActionText(reportAction: PartialReportAction): string { // Sometime html can be an empty string // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const text = (message?.html || message?.text) ?? ''; - return text ? parseHtmlToText(text) : ''; + return text ? Parser.htmlToText(text) : ''; } function getTextFromHtml(html?: string): string { - return html ? parseHtmlToText(html) : ''; + return html ? Parser.htmlToText(html) : ''; } function getMemberChangeMessageFragment(reportAction: OnyxEntry): Message { @@ -1204,7 +1142,6 @@ function isOldDotReportAction(action: ReportAction | OldDotReportAction) { CONST.REPORT.ACTIONS.TYPE.SHARE, CONST.REPORT.ACTIONS.TYPE.STRIPE_PAID, CONST.REPORT.ACTIONS.TYPE.TAKE_CONTROL, - CONST.REPORT.ACTIONS.TYPE.UNAPPROVED, CONST.REPORT.ACTIONS.TYPE.UNSHARE, CONST.REPORT.ACTIONS.TYPE.DELETED_ACCOUNT, CONST.REPORT.ACTIONS.TYPE.DONATION, @@ -1239,7 +1176,7 @@ function getMessageOfOldDotReportAction(oldDotAction: PartialReportAction | OldD case CONST.REPORT.ACTIONS.TYPE.CHANGE_FIELD: { const {oldValue, newValue, fieldName} = originalMessage; if (!oldValue) { - Localize.translateLocal('report.actions.type.changeFieldEmpty', {newValue, fieldName}); + return Localize.translateLocal('report.actions.type.changeFieldEmpty', {newValue, fieldName}); } return Localize.translateLocal('report.actions.type.changeField', {oldValue, newValue, fieldName}); } @@ -1256,8 +1193,9 @@ function getMessageOfOldDotReportAction(oldDotAction: PartialReportAction | OldD case CONST.REPORT.ACTIONS.TYPE.EXPORTED_TO_INTEGRATION: return Localize.translateLocal('report.actions.type.exportedToIntegration', {label: originalMessage.label}); case CONST.REPORT.ACTIONS.TYPE.INTEGRATIONS_MESSAGE: { - const {errorMessage, label} = originalMessage; - return Localize.translateLocal('report.actions.type.integrationsMessage', {errorMessage, label}); + const {result, label} = originalMessage; + const errorMessage = result?.messages?.join(', ') ?? ''; + return Localize.translateLocal('report.actions.type.integrationsMessage', errorMessage, label); } case CONST.REPORT.ACTIONS.TYPE.MANAGER_ATTACH_RECEIPT: return Localize.translateLocal('report.actions.type.managerAttachReceipt'); @@ -1395,8 +1333,7 @@ function isActionableJoinRequest(reportAction: OnyxEntry): reportA * @param reportID */ function isActionableJoinRequestPending(reportID: string): boolean { - const sortedReportActions = getSortedReportActions(Object.values(getAllReportActions(reportID))); - const findPendingRequest = sortedReportActions.find( + const findPendingRequest = Object.values(getAllReportActions(reportID)).find( (reportActionItem) => isActionableJoinRequest(reportActionItem) && getOriginalMessage(reportActionItem)?.choice === ('' as JoinWorkspaceResolution), ); return !!findPendingRequest; @@ -1442,7 +1379,7 @@ function wasActionTakenByCurrentUser(reportAction: OnyxInputOrEntry { - const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; + const report = ReportConnection.getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; const reportActions = getAllReportActions(report?.reportID ?? ''); const action = Object.values(reportActions ?? {})?.find((reportAction) => { const IOUTransactionID = isMoneyRequestAction(reportAction) ? getOriginalMessage(reportAction)?.IOUTransactionID : -1; @@ -1509,7 +1446,6 @@ export { shouldReportActionBeVisible, shouldHideNewMarker, shouldReportActionBeVisibleAsLastAction, - getContinuousReportActionChain, hasRequestFromCurrentAccount, getFirstVisibleReportActionID, isMemberChangeAction, @@ -1544,6 +1480,7 @@ export { getTextFromHtml, isTripPreview, getIOUActionForReportID, + getFilteredForOneTransactionView, }; export type {LastVisibleMessage}; diff --git a/src/libs/ReportConnection.ts b/src/libs/ReportConnection.ts new file mode 100644 index 000000000000..86e73229e84b --- /dev/null +++ b/src/libs/ReportConnection.ts @@ -0,0 +1,47 @@ +import type {OnyxCollection} from 'react-native-onyx'; +import Onyx from 'react-native-onyx'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Report} from '@src/types/onyx'; +import * as PriorityModeActions from './actions/PriorityMode'; +import * as ReportHelperActions from './actions/Report'; + +// Dynamic Import to avoid circular dependency +const UnreadIndicatorUpdaterHelper = () => import('./UnreadIndicatorUpdater'); + +const reportIDToNameMap: Record = {}; +let allReports: OnyxCollection; +Onyx.connect({ + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + callback: (value) => { + allReports = value; + UnreadIndicatorUpdaterHelper().then((module) => { + module.triggerUnreadUpdate(); + }); + // Each time a new report is added we will check to see if the user should be switched + PriorityModeActions.autoSwitchToFocusMode(); + + if (!value) { + return; + } + Object.values(value).forEach((report) => { + if (!report) { + return; + } + reportIDToNameMap[report.reportID] = report.reportName ?? report.displayName ?? report.reportID; + ReportHelperActions.handleReportChanged(report); + }); + }, +}); + +// This function is used to get all reports +function getAllReports() { + return allReports; +} + +// This function is used to get all reports name map +function getAllReportsNameMap() { + return reportIDToNameMap; +} + +export {getAllReports, getAllReportsNameMap}; diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index cf47864a779e..2045a37e0f27 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -1,5 +1,5 @@ import {format} from 'date-fns'; -import {ExpensiMark, Str} from 'expensify-common'; +import {Str} from 'expensify-common'; import {isEmpty} from 'lodash'; import lodashEscape from 'lodash/escape'; import lodashFindLastIndex from 'lodash/findLastIndex'; @@ -63,13 +63,14 @@ import ModifiedExpenseMessage from './ModifiedExpenseMessage'; import linkingConfig from './Navigation/linkingConfig'; import Navigation from './Navigation/Navigation'; import * as NumberUtils from './NumberUtils'; -import {parseHtmlToText} from './OnyxAwareParser'; +import Parser from './Parser'; import Permissions from './Permissions'; import * as PersonalDetailsUtils from './PersonalDetailsUtils'; import * as PhoneNumber from './PhoneNumber'; import * as PolicyUtils from './PolicyUtils'; import type {LastVisibleMessage} from './ReportActionsUtils'; import * as ReportActionsUtils from './ReportActionsUtils'; +import * as ReportConnection from './ReportConnection'; import StringUtils from './StringUtils'; import * as SubscriptionUtils from './SubscriptionUtils'; import * as TransactionUtils from './TransactionUtils'; @@ -187,6 +188,11 @@ type OptimisticApprovedReportAction = Pick< 'actionName' | 'actorAccountID' | 'automatic' | 'avatar' | 'isAttachment' | 'originalMessage' | 'message' | 'person' | 'reportActionID' | 'shouldShow' | 'created' | 'pendingAction' >; +type OptimisticUnapprovedReportAction = Pick< + ReportAction, + 'actionName' | 'actorAccountID' | 'automatic' | 'avatar' | 'isAttachment' | 'originalMessage' | 'message' | 'person' | 'reportActionID' | 'shouldShow' | 'created' | 'pendingAction' +>; + type OptimisticSubmittedReportAction = Pick< ReportAction, | 'actionName' @@ -473,7 +479,6 @@ let isAnonymousUser = false; const parsedReportActionMessageCache: Record = {}; const defaultAvatarBuildingIconTestID = 'SvgDefaultAvatarBuilding Icon'; - Onyx.connect({ key: ONYXKEYS.SESSION, callback: (value) => { @@ -501,13 +506,6 @@ Onyx.connect({ }, }); -let allReports: OnyxCollection; -Onyx.connect({ - key: ONYXKEYS.COLLECTION.REPORT, - waitForCollectionCallback: true, - callback: (value) => (allReports = value), -}); - let allReportsDraft: OnyxCollection; Onyx.connect({ key: ONYXKEYS.COLLECTION.REPORT_DRAFT, @@ -552,23 +550,6 @@ Onyx.connect({ }, }); -let lastUpdatedReport: OnyxEntry; - -Onyx.connect({ - key: ONYXKEYS.COLLECTION.REPORT, - callback: (value) => { - if (!value) { - return; - } - - lastUpdatedReport = value; - }, -}); - -function getLastUpdatedReport(): OnyxEntry { - return lastUpdatedReport; -} - function getCurrentUserAvatar(): AvatarSource | undefined { return currentUserPersonalDetails?.avatar; } @@ -585,6 +566,7 @@ function getChatType(report: OnyxInputOrEntry | Participant): ValueOf { + const allReports = ReportConnection.getAllReports(); if (!allReports && !allReportsDraft) { return undefined; } @@ -611,7 +593,7 @@ function getParentReport(report: OnyxEntry): OnyxEntry { if (!report?.parentReportID) { return undefined; } - return allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${report.parentReportID}`]; + return ReportConnection.getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${report.parentReportID}`]; } /** @@ -652,17 +634,18 @@ function getPolicyType(report: OnyxInputOrEntry, policies: OnyxCollectio return policies?.[`${ONYXKEYS.COLLECTION.POLICY}${report?.policyID}`]?.type ?? ''; } +const unavailableTranslation = Localize.translateLocal('workspace.common.unavailable'); /** * Get the policy name from a given report */ function getPolicyName(report: OnyxInputOrEntry, returnEmptyIfNotFound = false, policy?: OnyxInputOrEntry): string { - const noPolicyFound = returnEmptyIfNotFound ? '' : Localize.translateLocal('workspace.common.unavailable'); + const noPolicyFound = returnEmptyIfNotFound ? '' : unavailableTranslation; if (isEmptyObject(report)) { return noPolicyFound; } if ((!allPolicies || Object.keys(allPolicies).length === 0) && !report?.policyName) { - return Localize.translateLocal('workspace.common.unavailable'); + return unavailableTranslation; } const finalPolicy = policy ?? allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${report?.policyID}`]; @@ -706,7 +689,7 @@ function isExpenseReport(report: OnyxInputOrEntry): boolean { * Checks if a report is an IOU report using report or reportID */ function isIOUReport(reportOrID: OnyxInputOrEntry | string): boolean { - const report = typeof reportOrID === 'string' ? allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportOrID}`] ?? null : reportOrID; + const report = typeof reportOrID === 'string' ? ReportConnection.getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${reportOrID}`] ?? null : reportOrID; return report?.type === CONST.REPORT.TYPE.IOU; } @@ -771,13 +754,20 @@ function isReportManager(report: OnyxEntry): boolean { * Checks if the supplied report has been approved */ function isReportApproved(reportOrID: OnyxInputOrEntry | string, parentReportAction: OnyxEntry = undefined): boolean { - const report = typeof reportOrID === 'string' ? allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportOrID}`] ?? null : reportOrID; + const report = typeof reportOrID === 'string' ? ReportConnection.getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${reportOrID}`] ?? null : reportOrID; if (!report) { return parentReportAction?.childStateNum === CONST.REPORT.STATE_NUM.APPROVED && parentReportAction?.childStatusNum === CONST.REPORT.STATUS_NUM.APPROVED; } return report?.stateNum === CONST.REPORT.STATE_NUM.APPROVED && report?.statusNum === CONST.REPORT.STATUS_NUM.APPROVED; } +/** + * Checks if the supplied report has been manually reimbursed + */ +function isReportManuallyReimbursed(report: OnyxEntry): boolean { + return report?.stateNum === CONST.REPORT.STATE_NUM.APPROVED && report?.statusNum === CONST.REPORT.STATUS_NUM.REIMBURSED; +} + /** * Checks if the supplied report is an expense report in Open state and status. */ @@ -808,6 +798,7 @@ function hasParticipantInArray(report: OnyxEntry, memberAccountIDs: numb * Whether the Money Request report is settled */ function isSettled(reportID: string | undefined): boolean { + const allReports = ReportConnection.getAllReports(); if (!allReports || !reportID) { return false; } @@ -829,6 +820,7 @@ function isSettled(reportID: string | undefined): boolean { * Whether the current user is the submitter of the report */ function isCurrentUserSubmitter(reportID: string): boolean { + const allReports = ReportConnection.getAllReports(); if (!allReports) { return false; } @@ -891,7 +883,7 @@ function isInvoiceRoom(report: OnyxEntry): boolean { function isInvoiceRoomWithID(reportID?: string): boolean { // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID || -1}`]; + const report = ReportConnection.getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID || -1}`]; return isInvoiceRoom(report); } @@ -972,7 +964,7 @@ function isOpenInvoiceReport(report: OnyxEntry): boolean { * Whether the provided report is a chat room */ function isChatRoom(report: OnyxEntry): boolean { - return isUserCreatedPolicyRoom(report) || isDefaultRoom(report) || isInvoiceRoom(report); + return isUserCreatedPolicyRoom(report) || isDefaultRoom(report) || isInvoiceRoom(report) || isTripRoom(report); } /** @@ -1011,7 +1003,7 @@ function isWorkspaceTaskReport(report: OnyxEntry): boolean { if (!isTaskReport(report)) { return false; } - const parentReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${report?.parentReportID}`]; + const parentReport = ReportConnection.getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${report?.parentReportID}`]; return isPolicyExpenseChat(parentReport); } @@ -1052,13 +1044,12 @@ function isSystemChat(report: OnyxEntry): boolean { * Only returns true if this is our main 1:1 DM report with Concierge. */ function isConciergeChatReport(report: OnyxInputOrEntry): boolean { - const participantAccountIDs = Object.keys(report?.participants ?? {}) - .map(Number) - .filter((accountID) => accountID !== currentUserAccountID); - return participantAccountIDs.length === 1 && participantAccountIDs[0] === CONST.ACCOUNT_ID.CONCIERGE && !isChatThread(report); + const participantAccountIDs = Object.keys(report?.participants ?? {}); + return participantAccountIDs.length === 1 && Number(participantAccountIDs[0]) === CONST.ACCOUNT_ID.CONCIERGE && !isChatThread(report); } function findSelfDMReportID(): string | undefined { + const allReports = ReportConnection.getAllReports(); if (!allReports) { return; } @@ -1111,12 +1102,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; @@ -1265,7 +1257,7 @@ function isArchivedRoom(report: OnyxInputOrEntry, reportNameValuePairs?: */ function isArchivedRoomWithID(reportID?: string) { // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID || -1}`]; + const report = ReportConnection.getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID || -1}`]; return isArchivedRoom(report); } @@ -1397,7 +1389,7 @@ function isChildReport(report: OnyxEntry): boolean { function isExpenseRequest(report: OnyxInputOrEntry): boolean { if (isThread(report)) { const parentReportAction = ReportActionsUtils.getParentReportAction(report); - const parentReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${report?.parentReportID}`]; + const parentReport = ReportConnection.getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${report?.parentReportID}`]; return isExpenseReport(parentReport) && !isEmptyObject(parentReportAction) && ReportActionsUtils.isTransactionThread(parentReportAction); } return false; @@ -1410,7 +1402,7 @@ function isExpenseRequest(report: OnyxInputOrEntry): boolean { function isIOURequest(report: OnyxInputOrEntry): boolean { if (isThread(report)) { const parentReportAction = ReportActionsUtils.getParentReportAction(report); - const parentReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${report?.parentReportID}`]; + const parentReport = ReportConnection.getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${report?.parentReportID}`]; return isIOUReport(parentReport) && !isEmptyObject(parentReportAction) && ReportActionsUtils.isTransactionThread(parentReportAction); } return false; @@ -1432,7 +1424,7 @@ function isTrackExpenseReport(report: OnyxInputOrEntry): boolean { * Checks if a report is an IOU or expense request. */ function isMoneyRequest(reportOrID: OnyxEntry | string): boolean { - const report = typeof reportOrID === 'string' ? allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportOrID}`] ?? null : reportOrID; + const report = typeof reportOrID === 'string' ? ReportConnection.getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${reportOrID}`] ?? null : reportOrID; return isIOURequest(report) || isExpenseRequest(report); } @@ -1440,10 +1432,26 @@ function isMoneyRequest(reportOrID: OnyxEntry | string): boolean { * Checks if a report is an IOU or expense report. */ function isMoneyRequestReport(reportOrID: OnyxInputOrEntry | string): boolean { - const report = typeof reportOrID === 'string' ? allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportOrID}`] ?? null : reportOrID; + const report = typeof reportOrID === 'string' ? ReportConnection.getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${reportOrID}`] ?? null : reportOrID; 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 +1551,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; } @@ -1646,7 +1659,7 @@ function getReportRecipientAccountIDs(report: OnyxEntry, currentLoginAcc // In 1:1 chat threads, the participants will be the same as parent report. If a report is specifically a 1:1 chat thread then we will // get parent report and use its participants array. if (isThread(report) && !(isTaskReport(report) || isMoneyRequestReport(report))) { - const parentReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${report?.parentReportID}`]; + const parentReport = ReportConnection.getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${report?.parentReportID}`]; if (isOneOnOneChat(parentReport)) { finalReport = parentReport; } @@ -1835,6 +1848,7 @@ function getPersonalDetailsForAccountID(accountID: number): Partial | string, shouldUseShortDisplayName = true, ): string { - const report = typeof reportOrID === 'string' ? allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportOrID}`] : reportOrID; + const report = typeof reportOrID === 'string' ? ReportConnection.getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${reportOrID}`] : reportOrID; const submitterDisplayName = getDisplayNameForParticipant(report?.ownerAccountID, shouldUseShortDisplayName) ?? ''; const originalMessage = ReportActionsUtils.getOriginalMessage(reportAction); let messageKey: TranslationPaths; @@ -2216,7 +2230,7 @@ function getReimbursementDeQueuedActionMessage( reportOrID: OnyxEntry | string, isLHNPreview = false, ): string { - const report = typeof reportOrID === 'string' ? allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportOrID}`] : reportOrID; + const report = typeof reportOrID === 'string' ? ReportConnection.getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${reportOrID}`] : reportOrID; const originalMessage = ReportActionsUtils.getOriginalMessage(reportAction); const amount = originalMessage?.amount; const currency = originalMessage?.currency; @@ -2370,7 +2384,7 @@ function hasNonReimbursableTransactions(iouReportID: string | undefined): boolea } function getMoneyRequestSpendBreakdown(report: OnyxInputOrEntry, allReportsDict?: OnyxCollection): SpendBreakdown { - const allAvailableReports = allReportsDict ?? allReports; + const allAvailableReports = allReportsDict ?? ReportConnection.getAllReports(); let moneyRequestReport; if (isMoneyRequestReport(report) || isInvoiceReport(report)) { moneyRequestReport = report; @@ -2717,7 +2731,7 @@ function canEditFieldOfMoneyRequest(reportAction: OnyxInputOrEntry } const iouMessage = ReportActionsUtils.getOriginalMessage(reportAction); - const moneyRequestReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${iouMessage?.IOUReportID}`] ?? ({} as Report); + const moneyRequestReport = ReportConnection.getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${iouMessage?.IOUReportID}`] ?? ({} as Report); const transaction = allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${iouMessage?.IOUTransactionID}`] ?? ({} as Transaction); if (isSettled(String(moneyRequestReport.reportID)) || isReportApproved(String(moneyRequestReport.reportID))) { @@ -2965,7 +2979,7 @@ function getReportPreviewMessage( isForListPreview = false, originalReportAction: OnyxInputOrEntry = iouReportAction, ): string { - const report = typeof reportOrID === 'string' ? allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportOrID}`] : reportOrID; + const report = typeof reportOrID === 'string' ? ReportConnection.getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${reportOrID}`] : reportOrID; const reportActionMessage = ReportActionsUtils.getReportActionHtml(iouReportAction); if (isEmptyObject(report) || !report?.reportID) { @@ -3288,7 +3302,7 @@ function parseReportActionHtmlToText(reportAction: OnyxEntry, repo const logins = PersonalDetailsUtils.getLoginsByAccountIDs(accountIDs); accountIDs.forEach((id, index) => (accountIDToName[id] = logins[index])); - const textMessage = Str.removeSMSDomain(parseHtmlToText(html, reportIDToName, accountIDToName)); + const textMessage = Str.removeSMSDomain(Parser.htmlToText(html, {reportIDToName, accountIDToName})); parsedReportActionMessageCache[key] = textMessage; return textMessage; @@ -3433,10 +3447,13 @@ function getReportName(report: OnyxEntry, policy?: OnyxEntry, pa } // Not a room or PolicyExpenseChat, generate title from first 5 other participants - const participantsWithoutCurrentUser = Object.keys(report?.participants ?? {}) - .map(Number) - .filter((accountID) => accountID !== currentUserAccountID) - .slice(0, 5); + const participantsWithoutCurrentUser: number[] = []; + Object.keys(report?.participants ?? {}).forEach((accountID) => { + const accID = Number(accountID); + if (accID !== currentUserAccountID && participantsWithoutCurrentUser.length < 5) { + participantsWithoutCurrentUser.push(accID); + } + }); const isMultipleParticipantReport = participantsWithoutCurrentUser.length > 1; return participantsWithoutCurrentUser.map((accountID) => getDisplayNameForParticipant(accountID, isMultipleParticipantReport)).join(', '); } @@ -3640,7 +3657,6 @@ function getParsedComment(text: string, parsingDetails?: ParsingDetails): string isGroupPolicyReport = isReportInGroupPolicy(currentReport); } - const parser = new ExpensiMark(); const textWithMention = text.replace(CONST.REGEX.SHORT_MENTION, (match) => { if (!Str.isValidMention(match)) { return match; @@ -3651,7 +3667,7 @@ function getParsedComment(text: string, parsingDetails?: ParsingDetails): string }); return text.length <= CONST.MAX_MARKUP_LENGTH - ? parser.replace(textWithMention, {shouldEscapeText: parsingDetails?.shouldEscapeText, disabledRules: isGroupPolicyReport ? [] : ['reportMentions']}) + ? Parser.replace(textWithMention, {shouldEscapeText: parsingDetails?.shouldEscapeText, disabledRules: isGroupPolicyReport ? [] : ['reportMentions']}) : lodashEscape(text); } @@ -3660,7 +3676,7 @@ function getReportDescriptionText(report: Report): string { return ''; } - return parseHtmlToText(report.description); + return Parser.htmlToText(report.description); } function getPolicyDescriptionText(policy: OnyxEntry): string { @@ -3668,7 +3684,7 @@ function getPolicyDescriptionText(policy: OnyxEntry): string { return ''; } - return parseHtmlToText(policy.description); + return Parser.htmlToText(policy.description); } function buildOptimisticAddCommentReportAction( @@ -3690,10 +3706,10 @@ function buildOptimisticAddCommentReportAction( textForNewComment = CONST.ATTACHMENT_UPLOADING_MESSAGE_HTML; } else if (isTextOnly) { htmlForNewComment = commentText; - textForNewComment = parseHtmlToText(htmlForNewComment); + textForNewComment = Parser.htmlToText(htmlForNewComment); } else { htmlForNewComment = `${commentText}${CONST.ATTACHMENT_UPLOADING_MESSAGE_HTML}`; - textForNewComment = `${parseHtmlToText(commentText)}\n${CONST.ATTACHMENT_UPLOADING_MESSAGE_HTML}`; + textForNewComment = `${Parser.htmlToText(commentText)}\n${CONST.ATTACHMENT_UPLOADING_MESSAGE_HTML}`; } const isAttachment = !text && file !== undefined; @@ -3937,7 +3953,7 @@ function buildOptimisticInvoiceReport(chatReportID: string, policyID: string, re function buildOptimisticExpenseReport(chatReportID: string, policyID: string, payeeAccountID: number, total: number, currency: string, reimbursable = true): OptimisticExpenseReport { // The amount for Expense reports are stored as negative value in the database const storedTotal = total * -1; - const policyName = getPolicyName(allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${chatReportID}`]); + const policyName = getPolicyName(ReportConnection.getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${chatReportID}`]); const formattedTotal = CurrencyUtils.convertToDisplayString(storedTotal, currency); const policy = getPolicy(policyID); @@ -3978,77 +3994,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 +4012,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) @@ -4088,6 +4033,9 @@ function getIOUReportActionMessage(iouReportID: string, type: string, total: num case CONST.REPORT.ACTIONS.TYPE.APPROVED: iouMessage = `approved ${amount}`; break; + case CONST.REPORT.ACTIONS.TYPE.UNAPPROVED: + iouMessage = `unapproved ${amount}`; + break; case CONST.IOU.REPORT_ACTION_TYPE.CREATE: iouMessage = `submitted ${amount}${comment && ` for ${comment}`}`; break; @@ -4103,6 +4051,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; } @@ -4247,6 +4198,36 @@ function buildOptimisticApprovedReportAction(amount: number, currency: string, e }; } +/** + * Builds an optimistic APPROVED report action with a randomly generated reportActionID. + */ +function buildOptimisticUnapprovedReportAction(amount: number, currency: string, expenseReportID: string): OptimisticUnapprovedReportAction { + return { + actionName: CONST.REPORT.ACTIONS.TYPE.UNAPPROVED, + actorAccountID: currentUserAccountID, + automatic: false, + avatar: getCurrentUserAvatar(), + isAttachment: false, + originalMessage: { + amount, + currency, + expenseReportID, + }, + message: getIOUReportActionMessage(expenseReportID, CONST.REPORT.ACTIONS.TYPE.UNAPPROVED, Math.abs(amount), '', currency), + person: [ + { + style: 'strong', + text: getCurrentUserDisplayNameOrEmail(), + type: 'TEXT', + }, + ], + reportActionID: NumberUtils.rand64(), + shouldShow: true, + created: DateUtils.getDBTime(), + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + }; +} + /** * Builds an optimistic MOVED report action with a randomly generated reportActionID. * This action is used when we move reports across workspaces. @@ -4402,7 +4383,6 @@ function buildOptimisticActionableTrackExpenseWhisper(iouAction: OptimisticIOURe type: 'TEXT', }, ], - previousReportActionID: iouAction?.reportActionID, reportActionID, shouldShow: true, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, @@ -5163,7 +5143,7 @@ function buildTransactionThread( participantAccountIDs, getTransactionReportName(reportAction), undefined, - moneyRequestReport?.policyID, + moneyRequestReport?.policyID ?? '-1', CONST.POLICY.OWNER_ACCOUNT_ID_FAKE, false, '', @@ -5244,7 +5224,7 @@ function isEmptyReport(report: OnyxEntry): boolean { if (!report) { return true; } - const lastVisibleMessage = ReportActionsUtils.getLastVisibleMessage(report.reportID); + const lastVisibleMessage = getLastVisibleMessage(report.reportID); return !report.lastMessageText && !report.lastMessageTranslationKey && !lastVisibleMessage.lastMessageText && !lastVisibleMessage.lastMessageTranslationKey; } @@ -5266,7 +5246,7 @@ function isUnread(report: OnyxEntry): boolean { } function isIOUOwnedByCurrentUser(report: OnyxEntry, allReportsDict?: OnyxCollection): boolean { - const allAvailableReports = allReportsDict ?? allReports; + const allAvailableReports = allReportsDict ?? ReportConnection.getAllReports(); if (!report || !allAvailableReports) { return false; } @@ -5409,6 +5389,8 @@ function shouldReportBeInOptionList({ excludeEmptyChats, doesReportHaveViolations, includeSelfDM = false, + login, + includeDomainEmail = false, }: { report: OnyxEntry; currentReportId: string; @@ -5418,6 +5400,8 @@ function shouldReportBeInOptionList({ excludeEmptyChats: boolean; doesReportHaveViolations: boolean; includeSelfDM?: boolean; + login?: string; + includeDomainEmail?: boolean; }) { const isInDefaultMode = !isInFocusMode; // Exclude reports that have no data because there wouldn't be anything to show in the option item. @@ -5425,30 +5409,28 @@ function shouldReportBeInOptionList({ // This can also happen for anyone accessing a public room or archived room for which they don't have access to the underlying policy. // Optionally exclude reports that do not belong to currently active workspace - const participantAccountIDs = Object.keys(report?.participants ?? {}).map(Number); - if ( !report?.reportID || !report?.type || report?.reportName === undefined || // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing report?.isHidden || - (participantAccountIDs.length === 0 && + (!report?.participants && + // We omit sending back participants for chat rooms when searching for reports since they aren't needed to display the results and can get very large. + // So we allow showing rooms with no participants–in any other circumstances we should never have these reports with no participants in Onyx. + !isChatRoom(report) && !isChatThread(report) && - !isPublicRoom(report) && - !isUserCreatedPolicyRoom(report) && !isArchivedRoom(report) && !isMoneyRequestReport(report) && !isTaskReport(report) && !isSelfDM(report) && !isSystemChat(report) && - !isGroupChat(report) && - !isInvoiceRoom(report)) + !isGroupChat(report)) ) { return false; } - if (participantAccountIDs.includes(CONST.ACCOUNT_ID.NOTIFICATIONS) && (!currentUserAccountID || !AccountUtils.isAccountIDOddNumber(currentUserAccountID))) { + if (report?.participants?.[CONST.ACCOUNT_ID.NOTIFICATIONS] && (!currentUserAccountID || !AccountUtils.isAccountIDOddNumber(currentUserAccountID))) { return false; } @@ -5521,6 +5503,11 @@ function shouldReportBeInOptionList({ if (isSelfDM(report)) { return includeSelfDM; } + + if (Str.isDomainEmail(login ?? '') && !includeDomainEmail) { + return false; + } + const parentReportAction = ReportActionsUtils.getParentReportAction(report); // Hide chat threads where the parent message is pending removal @@ -5539,6 +5526,7 @@ function shouldReportBeInOptionList({ * Returns the system report from the list of reports. */ function getSystemChat(): OnyxEntry { + const allReports = ReportConnection.getAllReports(); if (!allReports) { return undefined; } @@ -5549,7 +5537,7 @@ function getSystemChat(): OnyxEntry { /** * Attempts to find a report in onyx with the provided list of participants. Does not include threads, task, expense, room, and policy expense chat. */ -function getChatByParticipants(newParticipantList: number[], reports: OnyxCollection = allReports, shouldIncludeGroupChats = false): OnyxEntry { +function getChatByParticipants(newParticipantList: number[], reports: OnyxCollection = ReportConnection.getAllReports(), shouldIncludeGroupChats = false): OnyxEntry { const sortedNewParticipantList = newParticipantList.sort(); return Object.values(reports ?? {}).find((report) => { const participantAccountIDs = Object.keys(report?.participants ?? {}); @@ -5577,7 +5565,7 @@ function getChatByParticipants(newParticipantList: number[], reports: OnyxCollec /** * Attempts to find an invoice chat report in onyx with the provided policyID and receiverID. */ -function getInvoiceChatByParticipants(policyID: string, receiverID: string | number, reports: OnyxCollection = allReports): OnyxEntry { +function getInvoiceChatByParticipants(policyID: string, receiverID: string | number, reports: OnyxCollection = ReportConnection.getAllReports()): OnyxEntry { return Object.values(reports ?? {}).find((report) => { if (!report || !isInvoiceRoom(report)) { return false; @@ -5596,7 +5584,7 @@ function getInvoiceChatByParticipants(policyID: string, receiverID: string | num * Attempts to find a policy expense report in onyx that is owned by ownerAccountID in a given policy */ function getPolicyExpenseChat(ownerAccountID: number, policyID: string): OnyxEntry { - return Object.values(allReports ?? {}).find((report: OnyxEntry) => { + return Object.values(ReportConnection.getAllReports() ?? {}).find((report: OnyxEntry) => { // If the report has been deleted, then skip it if (!report) { return false; @@ -5607,7 +5595,7 @@ function getPolicyExpenseChat(ownerAccountID: number, policyID: string): OnyxEnt } function getAllPolicyReports(policyID: string): Array> { - return Object.values(allReports ?? {}).filter((report) => report?.policyID === policyID); + return Object.values(ReportConnection.getAllReports() ?? {}).filter((report) => report?.policyID === policyID); } /** @@ -5620,7 +5608,7 @@ function chatIncludesChronos(report: OnyxInputOrEntry): boolean { function chatIncludesChronosWithID(reportID?: string): boolean { // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID || -1}`]; + const report = ReportConnection.getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID || -1}`]; return chatIncludesChronos(report); } @@ -5770,7 +5758,7 @@ function getReportIDFromLink(url: string | null): string { */ function hasIOUWaitingOnCurrentUserBankAccount(chatReport: OnyxInputOrEntry): boolean { if (chatReport?.iouReportID) { - const iouReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${chatReport?.iouReportID}`]; + const iouReport = ReportConnection.getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${chatReport?.iouReportID}`]; if (iouReport?.isWaitingOnBankAccount && iouReport?.ownerAccountID === currentUserAccountID) { return true; } @@ -6091,6 +6079,7 @@ function shouldReportShowSubscript(report: OnyxEntry): boolean { * Return true if reports data exists */ function isReportDataReady(): boolean { + const allReports = ReportConnection.getAllReports(); return !isEmptyObject(allReports) && Object.keys(allReports ?? {}).some((key) => allReports?.[key]?.reportID); } @@ -6114,7 +6103,7 @@ function getAddWorkspaceRoomOrChatReportErrors(report: OnyxEntry): Error * Return true if the expense report is marked for deletion. */ function isMoneyRequestReportPendingDeletion(reportOrID: OnyxEntry | string): boolean { - const report = typeof reportOrID === 'string' ? allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportOrID}`] : reportOrID; + const report = typeof reportOrID === 'string' ? ReportConnection.getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${reportOrID}`] : reportOrID; if (!isMoneyRequestReport(report)) { return false; } @@ -6142,7 +6131,9 @@ function getOriginalReportID(reportID: string, reportAction: OnyxInputOrEntry, policy: OnyxEntry, } function getWorkspaceChats(policyID: string, accountIDs: number[]): Array> { + const allReports = ReportConnection.getAllReports(); return Object.values(allReports ?? {}).filter((report) => isPolicyExpenseChat(report) && (report?.policyID ?? '-1') === policyID && accountIDs.includes(report?.ownerAccountID ?? -1)); } @@ -6185,6 +6177,7 @@ function getWorkspaceChats(policyID: string, accountIDs: number[]): Array> { + const allReports = ReportConnection.getAllReports(); return Object.values(allReports ?? {}).filter((report) => (report?.policyID ?? '-1') === policyID); } @@ -6489,7 +6482,7 @@ function shouldUseFullTitleToDisplay(report: OnyxEntry): boolean { } function getRoom(type: ValueOf, policyID: string): OnyxEntry { - const room = Object.values(allReports ?? {}).find((report) => report?.policyID === policyID && report?.chatType === type && !isThread(report)); + const room = Object.values(ReportConnection.getAllReports() ?? {}).find((report) => report?.policyID === policyID && report?.chatType === type && !isThread(report)); return room; } @@ -6617,15 +6610,18 @@ function getNonHeldAndFullAmount(iouReport: OnyxEntry, policy: OnyxEntry const transactions = TransactionUtils.getAllReportTransactions(iouReport?.reportID ?? '-1'); const hasPendingTransaction = transactions.some((transaction) => !!transaction.pendingAction); + // if the report is an expense report, the total amount should be negated + const coefficient = isExpenseReport(iouReport) ? -1 : 1; + if (hasUpdatedTotal(iouReport, policy) && hasPendingTransaction) { const unheldTotal = transactions.reduce((currentVal, transaction) => currentVal - (!TransactionUtils.isOnHold(transaction) ? transaction.amount : 0), 0); - return [CurrencyUtils.convertToDisplayString(unheldTotal, iouReport?.currency), CurrencyUtils.convertToDisplayString((iouReport?.total ?? 0) * -1, iouReport?.currency)]; + return [CurrencyUtils.convertToDisplayString(unheldTotal, iouReport?.currency), CurrencyUtils.convertToDisplayString((iouReport?.total ?? 0) * coefficient, iouReport?.currency)]; } return [ - CurrencyUtils.convertToDisplayString((iouReport?.unheldTotal ?? 0) * -1, iouReport?.currency), - CurrencyUtils.convertToDisplayString((iouReport?.total ?? 0) * -1, iouReport?.currency), + CurrencyUtils.convertToDisplayString((iouReport?.unheldTotal ?? 0) * coefficient, iouReport?.currency), + CurrencyUtils.convertToDisplayString((iouReport?.total ?? 0) * coefficient, iouReport?.currency), ]; } @@ -6855,7 +6851,7 @@ function shouldCreateNewMoneyRequestReport(existingIOUReport: OnyxInputOrEntry report && report?.[reportFieldToCompare] === tripRoomReportID) .map((report) => report?.reportID); return tripTransactionReportIDs.flatMap((reportID) => TransactionUtils.getAllReportTransactions(reportID)); @@ -6879,6 +6875,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 */ @@ -7041,10 +7043,11 @@ function canReportBeMentionedWithinPolicy(report: OnyxEntry, policyID: s return false; } - return isChatRoom(report) && !isThread(report); + return isChatRoom(report) && !isInvoiceRoom(report) && !isThread(report); } function shouldShowMerchantColumn(transactions: Transaction[]) { + const allReports = ReportConnection.getAllReports(); return transactions.some((transaction) => isExpenseReport(allReports?.[transaction.reportID] ?? null)); } @@ -7059,11 +7062,11 @@ function isChatUsedForOnboarding(report: OnyxEntry): boolean { * Get the report (system or concierge chat) used for the user's onboarding process. */ function getChatUsedForOnboarding(): OnyxEntry { - return Object.values(allReports ?? {}).find(isChatUsedForOnboarding); + return Object.values(ReportConnection.getAllReports() ?? {}).find(isChatUsedForOnboarding); } function findPolicyExpenseChatByPolicyID(policyID: string): OnyxEntry { - return Object.values(allReports ?? {}).find((report) => isPolicyExpenseChat(report) && report?.policyID === policyID); + return Object.values(ReportConnection.getAllReports() ?? {}).find((report) => isPolicyExpenseChat(report) && report?.policyID === policyID); } export { @@ -7071,6 +7074,7 @@ export { areAllRequestsBeingSmartScanned, buildOptimisticAddCommentReportAction, buildOptimisticApprovedReportAction, + buildOptimisticUnapprovedReportAction, buildOptimisticCancelPaymentReportAction, buildOptimisticChangedTaskAssigneeReportAction, buildOptimisticChatReport, @@ -7155,10 +7159,10 @@ export { getGroupChatName, getIOUReportActionDisplayMessage, getIOUReportActionMessage, + getIOUSubmittedMessage, getIcons, getIconsForParticipants, getIndicatedMissingPaymentMethod, - getLastUpdatedReport, getLastVisibleMessage, getMoneyRequestOptions, getMoneyRequestSpendBreakdown, @@ -7282,6 +7286,7 @@ export { isPublicAnnounceRoom, isPublicRoom, isReportApproved, + isReportManuallyReimbursed, isReportDataReady, isReportFieldDisabled, isReportFieldOfTypeTitle, @@ -7338,10 +7343,12 @@ export { isCurrentUserInvoiceReceiver, isDraftReport, changeMoneyRequestHoldStatus, + isAdminOwnerApproverOrReportOwner, createDraftWorkspaceAndNavigateToConfirmationScreen, isChatUsedForOnboarding, getChatUsedForOnboarding, findPolicyExpenseChatByPolicyID, + hasOnlyNonReimbursableTransactions, }; export type { diff --git a/src/libs/SearchUtils.ts b/src/libs/SearchUtils.ts index 460a686766a7..cb579e44b95d 100644 --- a/src/libs/SearchUtils.ts +++ b/src/libs/SearchUtils.ts @@ -1,11 +1,12 @@ import type {ValueOf} from 'react-native-gesture-handler/lib/typescript/typeUtils'; import ReportListItem from '@components/SelectionList/Search/ReportListItem'; import TransactionListItem from '@components/SelectionList/Search/TransactionListItem'; -import type {ReportListItemType, TransactionListItemType} from '@components/SelectionList/types'; +import type {ListItem, ReportListItemType, TransactionListItemType} from '@components/SelectionList/types'; 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'; @@ -79,10 +80,15 @@ function getShouldShowMerchant(data: OnyxTypes.SearchResults['data']): boolean { const currentYear = new Date().getFullYear(); -function isReportListItemType(item: TransactionListItemType | ReportListItemType): item is ReportListItemType { +function isReportListItemType(item: ListItem): item is ReportListItemType { return 'transactions' in item; } +function isTransactionListItemType(item: TransactionListItemType | ReportListItemType): item is TransactionListItemType { + const transactionListItem = item as TransactionListItemType; + return transactionListItem.transactionID !== undefined; +} + function shouldShowYear(data: TransactionListItemType[] | ReportListItemType[] | OnyxTypes.SearchResults['data']): boolean { if (Array.isArray(data)) { return data.some((item: TransactionListItemType | ReportListItemType) => { @@ -138,9 +144,9 @@ function getTransactionsSections(data: OnyxTypes.SearchResults['data'], metadata formattedMerchant, date, shouldShowMerchant, - shouldShowCategory: metadata?.columnsToShow.shouldShowCategoryColumn, - shouldShowTag: metadata?.columnsToShow.shouldShowTagColumn, - shouldShowTax: metadata?.columnsToShow.shouldShowTaxColumn, + shouldShowCategory: metadata?.columnsToShow?.shouldShowCategoryColumn, + shouldShowTag: metadata?.columnsToShow?.shouldShowTagColumn, + shouldShowTax: metadata?.columnsToShow?.shouldShowTaxColumn, keyForList: transactionItem.transactionID, shouldShowYear: doesDataContainAPastYearTransaction, }; @@ -161,6 +167,9 @@ function getReportSections(data: OnyxTypes.SearchResults['data'], metadata: Onyx reportIDToTransactions[reportKey] = { ...value, + keyForList: value.reportID, + from: data.personalDetailsList?.[value.accountID], + to: data.personalDetailsList?.[value.managerID], transactions, }; } else if (key.startsWith(ONYXKEYS.COLLECTION.TRANSACTION)) { @@ -185,16 +194,16 @@ function getReportSections(data: OnyxTypes.SearchResults['data'], metadata: Onyx formattedMerchant, date, shouldShowMerchant, - shouldShowCategory: metadata?.columnsToShow.shouldShowCategoryColumn, - shouldShowTag: metadata?.columnsToShow.shouldShowTagColumn, - shouldShowTax: metadata?.columnsToShow.shouldShowTaxColumn, + shouldShowCategory: metadata?.columnsToShow?.shouldShowCategoryColumn, + shouldShowTag: metadata?.columnsToShow?.shouldShowTagColumn, + shouldShowTax: metadata?.columnsToShow?.shouldShowTaxColumn, keyForList: transactionItem.transactionID, shouldShowYear: doesDataContainAPastYearTransaction, }; if (reportIDToTransactions[reportKey]?.transactions) { reportIDToTransactions[reportKey].transactions.push(transaction); } else { - reportIDToTransactions[reportKey] = {transactions: [transaction]}; + reportIDToTransactions[reportKey].transactions = [transaction]; } } } @@ -212,7 +221,7 @@ const searchTypeToItemMap: SearchTypeToItemMap = { listItem: ReportListItem, getSections: getReportSections, // sorting for ReportItems not yet implemented - getSortedSections: (data) => data, + getSortedSections: getSortedReportData, }, }; @@ -273,10 +282,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}; +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..4f227e04482a 100644 --- a/src/libs/SidebarUtils.ts +++ b/src/libs/SidebarUtils.ts @@ -1,7 +1,7 @@ import {Str} from 'expensify-common'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; -import type {ChatReportSelector, PolicySelector, ReportActionsSelector} from '@hooks/useReportIDs'; +import type {PolicySelector, ReportActionsSelector} from '@hooks/useReportIDs'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {PersonalDetails, PersonalDetailsList, ReportActions, TransactionViolation} from '@src/types/onyx'; @@ -68,7 +68,7 @@ type MiniReport = { */ function getOrderedReportIDs( currentReportId: string | null, - allReports: OnyxCollection, + allReports: OnyxCollection, betas: OnyxEntry, policies: OnyxCollection, priorityMode: OnyxEntry, @@ -82,7 +82,7 @@ function getOrderedReportIDs( const allReportsDictValues = Object.values(allReports ?? {}); // Filter out all the reports that shouldn't be displayed - let reportsToDisplay: Array = []; + let reportsToDisplay: Array = []; allReportsDictValues.forEach((report) => { if (!report) { return; @@ -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/StringUtils.ts b/src/libs/StringUtils.ts index 393ecddb4c36..fffd54506fcb 100644 --- a/src/libs/StringUtils.ts +++ b/src/libs/StringUtils.ts @@ -105,4 +105,13 @@ function lineBreaksToSpaces(text = '') { return text.replace(CONST.REGEX.LINE_BREAK, ' '); } -export default {sanitizeString, isEmptyString, removeInvisibleCharacters, normalizeAccents, normalizeCRLF, getAcronym, lineBreaksToSpaces}; +/** + * Get the first line of the string + */ +function getFirstLine(text = '') { + // Split the input string by newline characters and return the first element of the resulting array + const lines = text.split('\n'); + return lines[0]; +} + +export default {sanitizeString, isEmptyString, removeInvisibleCharacters, normalizeAccents, normalizeCRLF, getAcronym, lineBreaksToSpaces, getFirstLine}; diff --git a/src/libs/SubscriptionUtils.ts b/src/libs/SubscriptionUtils.ts index c8ce7a455906..9a4d1d1c5d28 100644 --- a/src/libs/SubscriptionUtils.ts +++ b/src/libs/SubscriptionUtils.ts @@ -5,6 +5,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {BillingGraceEndPeriod, BillingStatus, Fund, FundList, Policy, StripeCustomerID} from '@src/types/onyx'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; +import * as PolicyUtils from './PolicyUtils'; const PAYMENT_STATUS = { POLICY_OWNER_WITH_AMOUNT_OWED: 'policy_owner_with_amount_owed', @@ -21,6 +22,14 @@ const PAYMENT_STATUS = { GENERIC_API_ERROR: 'generic_api_error', } as const; +let currentUserAccountID = -1; +Onyx.connect({ + key: ONYXKEYS.SESSION, + callback: (value) => { + currentUserAccountID = value?.accountID ?? -1; + }, +}); + let amountOwed: OnyxEntry; Onyx.connect({ key: ONYXKEYS.NVP_PRIVATE_AMOUNT_OWED, @@ -72,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; @@ -341,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; } /** @@ -401,6 +411,8 @@ function doesUserHavePaymentCardAdded(): boolean { function shouldRestrictUserBillableActions(policyID: string): boolean { const currentDate = new Date(); + const policy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]; + // This logic will be executed if the user is a workspace's non-owner (normal user or admin). // We should restrict the workspace's non-owner actions if it's member of a workspace where the owner is // past due and is past its grace period end. @@ -409,10 +421,9 @@ function shouldRestrictUserBillableActions(policyID: string): boolean { if (userBillingGracePeriodEnd && isAfter(currentDate, fromUnixTime(userBillingGracePeriodEnd.value))) { // Extracts the owner account ID from the collection member key. - const ownerAccountID = entryKey.slice(ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_USER_BILLING_GRACE_PERIOD_END.length); + const ownerAccountID = Number(entryKey.slice(ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_USER_BILLING_GRACE_PERIOD_END.length)); - const ownerPolicy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]; - if (String(ownerPolicy?.ownerAccountID ?? -1) === ownerAccountID) { + if (PolicyUtils.isPolicyOwner(policy, ownerAccountID)) { return true; } } @@ -420,7 +431,13 @@ function shouldRestrictUserBillableActions(policyID: string): boolean { // If it reached here it means that the user is actually the workspace's owner. // We should restrict the workspace's owner actions if it's past its grace period end date and it's owing some amount. - if (ownerBillingGraceEndPeriod && amountOwed !== undefined && amountOwed > 0 && isAfter(currentDate, fromUnixTime(ownerBillingGraceEndPeriod))) { + if ( + PolicyUtils.isPolicyOwner(policy, currentUserAccountID) && + ownerBillingGraceEndPeriod && + amountOwed !== undefined && + amountOwed > 0 && + isAfter(currentDate, fromUnixTime(ownerBillingGraceEndPeriod)) + ) { return true; } diff --git a/src/libs/TaskUtils.ts b/src/libs/TaskUtils.ts index 332d82915463..bd0bd10cd83e 100644 --- a/src/libs/TaskUtils.ts +++ b/src/libs/TaskUtils.ts @@ -1,21 +1,11 @@ -import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; -import Onyx from 'react-native-onyx'; +import type {OnyxEntry} from 'react-native-onyx'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {Report} from '@src/types/onyx'; import type {Message} from '@src/types/onyx/ReportAction'; import type ReportAction from '@src/types/onyx/ReportAction'; import * as Localize from './Localize'; import {getReportActionHtml, getReportActionText} from './ReportActionsUtils'; - -let allReports: OnyxCollection = {}; -Onyx.connect({ - key: ONYXKEYS.COLLECTION.REPORT, - waitForCollectionCallback: true, - callback: (reports) => { - allReports = reports; - }, -}); +import * as ReportConnection from './ReportConnection'; /** * Given the Task reportAction name, return the appropriate message to be displayed and copied to clipboard. @@ -39,7 +29,7 @@ function getTaskReportActionMessage(action: OnyxEntry): Pick = {}; Onyx.connect({ @@ -34,13 +39,6 @@ Onyx.connect({ callback: (value) => (allTransactionViolations = value), }); -let allReports: OnyxCollection; -Onyx.connect({ - key: ONYXKEYS.COLLECTION.REPORT, - waitForCollectionCallback: true, - callback: (value) => (allReports = value), -}); - let currentUserEmail = ''; let currentUserAccountID = -1; Onyx.connect({ @@ -194,6 +192,7 @@ function isCreatedMissing(transaction: OnyxEntry) { } function areRequiredFieldsEmpty(transaction: OnyxEntry): boolean { + const allReports = ReportConnection.getAllReports(); const parentReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${transaction?.reportID}`] ?? null; const isFromExpenseReport = parentReport?.type === CONST.REPORT.TYPE.EXPENSE; const isSplitPolicyExpenseChat = !!transaction?.comment?.splits?.some((participant) => allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${participant.chatReportID}`]?.isOwnPolicyExpenseChat); @@ -387,6 +386,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. */ @@ -714,9 +720,11 @@ function hasWarningTypeViolation(transactionID: string, transactionViolations: O /** * Calculates tax amount from the given expense amount and tax percentage */ -function calculateTaxAmount(percentage: string, amount: number) { +function calculateTaxAmount(percentage: string, amount: number, currency: string) { const divisor = Number(percentage.slice(0, -1)) / 100 + 1; - return Math.round(amount - amount / divisor) / 100; + const taxAmount = (amount - amount / divisor) / 100; + const decimals = getCurrencyDecimals(currency); + return parseFloat(taxAmount.toFixed(decimals)); } /** @@ -810,6 +818,101 @@ function getTransaction(transactionID: string): OnyxEntry { return allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]; } +type FieldsToCompare = Record>; +type FieldsToChange = { + category?: Array; + merchant?: Array; + tag?: Array; + description?: Array; + taxCode?: Array; + billable?: Array; + reimbursable?: Array; +}; + +/** + * This function compares fields of duplicate transactions and determines which fields should be kept and which should be changed. + * + * @returns An object with two properties: 'keep' and 'change'. + * 'keep' is an object where each key is a field name and the value is the value of that field in the transaction that should be kept. + * 'change' is an object where each key is a field name and the value is an array of different values of that field in the duplicate transactions. + * + * The function works as follows: + * 1. It fetches the transaction violations for the given transaction ID. + * 2. It finds the duplicate transactions. + * 3. It creates two empty objects, 'keep' and 'change'. + * 4. It defines the fields to compare in the transactions. + * 5. It iterates over the fields to compare. For each field: + * - If the field is 'description', it checks if all comments are equal, exist, or are empty. If so, it keeps the first transaction's comment. Otherwise, it finds the different values and adds them to 'change'. + * - For other fields, it checks if all fields are equal. If so, it keeps the first transaction's field value. Otherwise, it finds the different values and adds them to 'change'. + * 6. It returns the 'keep' and 'change' objects. + */ + +function compareDuplicateTransactionFields(transactionID: string): {keep: Partial; change: FieldsToChange} { + const transactionViolations = allTransactionViolations?.[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`]; + const duplicates = transactionViolations?.find((violation) => violation.name === CONST.VIOLATIONS.DUPLICATED_TRANSACTION)?.data?.duplicates ?? []; + const transactions = [transactionID, ...duplicates].map((item) => getTransaction(item)); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const keep: Record = {}; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const change: Record = {}; + + const fieldsToCompare: FieldsToCompare = { + merchant: ['merchant', 'modifiedMerchant'], + category: ['category'], + tag: ['tag'], + description: ['comment'], + taxCode: ['taxCode'], + billable: ['billable'], + reimbursable: ['reimbursable'], + }; + + const getDifferentValues = (items: Array>, keys: Array) => [...new Set(items.map((item) => keys.map((key) => item?.[key])).flat())]; + + for (const fieldName in fieldsToCompare) { + if (Object.prototype.hasOwnProperty.call(fieldsToCompare, fieldName)) { + const keys = fieldsToCompare[fieldName]; + const firstTransaction = transactions[0]; + const isFirstTransactionCommentEmptyObject = typeof firstTransaction?.comment === 'object' && firstTransaction?.comment.comment === ''; + + if (fieldName === 'description') { + const allCommentsAreEqual = transactions.every((item) => lodashIsEqual(item?.comment, firstTransaction?.comment)); + const allCommentsExist = transactions.every((item) => !!item?.comment.comment === !!firstTransaction?.comment.comment); + const allCommentsAreEmpty = isFirstTransactionCommentEmptyObject && transactions.every((item) => item?.comment === undefined); + + if (allCommentsAreEqual || allCommentsExist || allCommentsAreEmpty) { + keep[fieldName] = firstTransaction?.comment.comment ?? firstTransaction?.comment; + } else { + const differentValues = getDifferentValues(transactions, keys); + if (differentValues.length > 0) { + change[fieldName] = differentValues; + } + } + } else { + const allFieldsAreEqual = transactions.every((item) => keys.every((key) => item?.[key] === firstTransaction?.[key])); + + if (allFieldsAreEqual) { + keep[fieldName] = firstTransaction?.[keys[0]]; + } else { + const differentValues = getDifferentValues(transactions, keys); + if (differentValues.length > 0) { + change[fieldName] = differentValues; + } + } + } + } + } + + return {keep, change}; +} + +function getTransactionID(threadReportID: string): string { + const report = ReportConnection.getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${threadReportID}`] ?? null; + const parentReportAction = ReportActionsUtils.getReportAction(report?.parentReportID ?? '', report?.parentReportActionID ?? ''); + const IOUTransactionID = ReportActionsUtils.isMoneyRequestAction(parentReportAction) ? ReportActionsUtils.getOriginalMessage(parentReportAction)?.IOUTransactionID ?? '-1' : '-1'; + + return IOUTransactionID; +} + export { buildOptimisticTransaction, calculateTaxAmount, @@ -878,6 +981,9 @@ export { isCustomUnitRateIDForP2P, getRateID, getTransaction, + compareDuplicateTransactionFields, + getTransactionID, + 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/UnreadIndicatorUpdater/index.ts b/src/libs/UnreadIndicatorUpdater/index.ts index 7698433c33c1..2546225bd6ea 100644 --- a/src/libs/UnreadIndicatorUpdater/index.ts +++ b/src/libs/UnreadIndicatorUpdater/index.ts @@ -1,17 +1,14 @@ import debounce from 'lodash/debounce'; import memoize from 'lodash/memoize'; import type {OnyxCollection} from 'react-native-onyx'; -import Onyx from 'react-native-onyx'; +import * as ReportConnection from '@libs/ReportConnection'; import * as ReportUtils from '@libs/ReportUtils'; import Navigation, {navigationRef} from '@navigation/Navigation'; import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; import type {Report} from '@src/types/onyx'; import updateUnread from './updateUnread'; -let allReports: OnyxCollection = {}; - -export default function getUnreadReportsForUnreadIndicator(reports: OnyxCollection, currentReportID: string) { +function getUnreadReportsForUnreadIndicator(reports: OnyxCollection, currentReportID: string) { return Object.values(reports ?? {}).filter( (report) => ReportUtils.isUnread(report) && @@ -40,23 +37,16 @@ export default function getUnreadReportsForUnreadIndicator(reports: OnyxCollecti const memoizedGetUnreadReportsForUnreadIndicator = memoize(getUnreadReportsForUnreadIndicator); const triggerUnreadUpdate = debounce(() => { - const currentReportID = navigationRef.isReady() ? Navigation.getTopmostReportId() ?? '-1' : '-1'; + const currentReportID = navigationRef?.isReady?.() ? Navigation.getTopmostReportId() ?? '-1' : '-1'; // We want to keep notification count consistent with what can be accessed from the LHN list - const unreadReports = memoizedGetUnreadReportsForUnreadIndicator(allReports, currentReportID); + const unreadReports = memoizedGetUnreadReportsForUnreadIndicator(ReportConnection.getAllReports(), currentReportID); updateUnread(unreadReports.length); }, CONST.TIMING.UNREAD_UPDATE_DEBOUNCE_TIME); -Onyx.connect({ - key: ONYXKEYS.COLLECTION.REPORT, - waitForCollectionCallback: true, - callback: (reportsFromOnyx) => { - allReports = reportsFromOnyx; - triggerUnreadUpdate(); - }, -}); - -navigationRef.addListener('state', () => { +navigationRef?.addListener?.('state', () => { triggerUnreadUpdate(); }); + +export {triggerUnreadUpdate, getUnreadReportsForUnreadIndicator}; diff --git a/src/libs/WorkspaceReportFieldUtils.ts b/src/libs/WorkspaceReportFieldUtils.ts new file mode 100644 index 000000000000..b7d93b8dee3a --- /dev/null +++ b/src/libs/WorkspaceReportFieldUtils.ts @@ -0,0 +1,89 @@ +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/WorkspaceReportFieldForm'; +import type {PolicyReportField, 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()}`; +} + +/** + * Gets the initial value for a report field. + */ +function getReportFieldInitialValue(reportField: PolicyReportField | null): string { + if (!reportField) { + return ''; + } + + if (reportField.type === CONST.REPORT_FIELD_TYPES.LIST) { + return reportField.defaultValue ?? ''; + } + + if (reportField.type === CONST.REPORT_FIELD_TYPES.DATE) { + return Localize.translateLocal('common.currentDate'); + } + + return reportField.value ?? reportField.defaultValue; +} + +export {getReportFieldTypeTranslationKey, getReportFieldAlternativeTextTranslationKey, validateReportFieldListValueName, generateFieldID, getReportFieldInitialValue}; diff --git a/src/libs/WorkspacesSettingsUtils.ts b/src/libs/WorkspacesSettingsUtils.ts index 62c034145d4b..d827a6cae000 100644 --- a/src/libs/WorkspacesSettingsUtils.ts +++ b/src/libs/WorkspacesSettingsUtils.ts @@ -10,20 +10,13 @@ import * as CurrencyUtils from './CurrencyUtils'; import type {Phrase, PhraseParameters} from './Localize'; import * as OptionsListUtils from './OptionsListUtils'; import {hasCustomUnitsError, hasEmployeeListError, hasPolicyError, hasTaxRateError} from './PolicyUtils'; +import * as ReportConnection from './ReportConnection'; import * as ReportUtils from './ReportUtils'; type CheckingMethod = () => boolean; -let allReports: OnyxCollection; - type BrickRoad = ValueOf | undefined; -Onyx.connect({ - key: ONYXKEYS.COLLECTION.REPORT, - waitForCollectionCallback: true, - callback: (value) => (allReports = value), -}); - let allPolicies: OnyxCollection; Onyx.connect({ @@ -100,6 +93,7 @@ function hasWorkspaceSettingsRBR(policy: Policy) { } function getChatTabBrickRoad(policyID?: string): BrickRoad | undefined { + const allReports = ReportConnection.getAllReports(); if (!allReports) { return undefined; } 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/Card.ts b/src/libs/actions/Card.ts index aea952618071..b23493c08e8e 100644 --- a/src/libs/actions/Card.ts +++ b/src/libs/actions/Card.ts @@ -5,10 +5,21 @@ import type {ActivatePhysicalExpensifyCardParams, ReportVirtualExpensifyCardFrau import {SIDE_EFFECT_REQUEST_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {ExpensifyCardDetails, IssueNewCardStep} from '@src/types/onyx/Card'; +import type {ExpensifyCardDetails, IssueNewCardData, IssueNewCardStep} from '@src/types/onyx/Card'; type ReplacementReason = 'damaged' | 'stolen'; +type IssueNewCardFlowData = { + /** Step to be set in Onyx */ + step?: IssueNewCardStep; + + /** Whether the user is editing step */ + isEditing?: boolean; + + /** Data required to be sent to issue a new card */ + data?: Partial; +}; + function reportVirtualExpensifyCardFraud(cardID: number) { const optimisticData: OnyxUpdate[] = [ { @@ -185,9 +196,24 @@ function revealVirtualCardDetails(cardID: number): Promise }); } -function setIssueNewCardStep(step: IssueNewCardStep | null) { - Onyx.merge(ONYXKEYS.ISSUE_NEW_EXPENSIFY_CARD, {currentStep: step}); +function setIssueNewCardStepAndData({data, isEditing, step}: IssueNewCardFlowData) { + Onyx.merge(ONYXKEYS.ISSUE_NEW_EXPENSIFY_CARD, {data, isEditing, currentStep: step}); +} + +function clearIssueNewCardFlow() { + Onyx.set(ONYXKEYS.ISSUE_NEW_EXPENSIFY_CARD, { + currentStep: null, + data: {}, + }); } -export {requestReplacementExpensifyCard, activatePhysicalExpensifyCard, clearCardListErrors, reportVirtualExpensifyCardFraud, revealVirtualCardDetails, setIssueNewCardStep}; +export { + requestReplacementExpensifyCard, + activatePhysicalExpensifyCard, + clearCardListErrors, + reportVirtualExpensifyCardFraud, + revealVirtualCardDetails, + setIssueNewCardStepAndData, + clearIssueNewCardFlow, +}; export type {ReplacementReason}; diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts index a4a53346aa9c..df199a2a36ae 100644 --- a/src/libs/actions/IOU.ts +++ b/src/libs/actions/IOU.ts @@ -24,6 +24,7 @@ import type { StartSplitBillParams, SubmitReportParams, TrackExpenseParams, + UnapproveExpenseReportParams, UpdateMoneyRequestParams, } from '@libs/API/parameters'; import {WRITE_COMMANDS} from '@libs/API/types'; @@ -42,8 +43,10 @@ import Permissions from '@libs/Permissions'; import * as PhoneNumber from '@libs/PhoneNumber'; import * as PolicyUtils from '@libs/PolicyUtils'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; +import * as ReportConnection from '@libs/ReportConnection'; import type {OptimisticChatReport, OptimisticCreatedReportAction, OptimisticIOUReportAction, TransactionDetails} from '@libs/ReportUtils'; import * as ReportUtils from '@libs/ReportUtils'; +import * as SubscriptionUtils from '@libs/SubscriptionUtils'; import * as TransactionUtils from '@libs/TransactionUtils'; import ViolationsUtils from '@libs/Violations/ViolationsUtils'; import type {IOUAction, IOUType} from '@src/CONST'; @@ -164,13 +167,6 @@ Onyx.connect({ }, }); -let allReports: OnyxCollection | null = null; -Onyx.connect({ - key: ONYXKEYS.COLLECTION.REPORT, - waitForCollectionCallback: true, - callback: (value) => (allReports = value ?? null), -}); - let allReportsDraft: OnyxCollection; Onyx.connect({ key: ONYXKEYS.COLLECTION.REPORT_DRAFT, @@ -294,6 +290,7 @@ Onyx.connect({ * Get the report or draft report given a reportID */ function getReportOrDraftReport(reportID: string | undefined): OnyxEntry { + const allReports = ReportConnection.getAllReports(); if (!allReports && !allReportsDraft) { return undefined; } @@ -501,7 +498,7 @@ function buildOnyxDataForMoneyRequest( if (TransactionUtils.isDistanceRequest(transaction)) { newQuickAction = CONST.QUICK_ACTIONS.REQUEST_DISTANCE; } - const existingTransactionThreadReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${existingTransactionThreadReportID}`] ?? null; + const existingTransactionThreadReport = ReportConnection.getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${existingTransactionThreadReportID}`] ?? null; if (chatReport) { optimisticData.push({ @@ -1217,7 +1214,7 @@ function buildOnyxDataForTrackExpense( } else if (isDistanceRequest) { newQuickAction = CONST.QUICK_ACTIONS.TRACK_DISTANCE; } - const existingTransactionThreadReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${existingTransactionThreadReportID}`] ?? null; + const existingTransactionThreadReport = ReportConnection.getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${existingTransactionThreadReportID}`] ?? null; if (chatReport) { optimisticData.push( @@ -1572,6 +1569,7 @@ function getDeleteTrackExpenseInformation( actionableWhisperReportActionID = '', resolution = '', ) { + const allReports = ReportConnection.getAllReports(); // STEP 1: Get all collections we're updating const chatReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${chatReportID}`] ?? null; const transaction = allTransactions[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]; @@ -1915,6 +1913,7 @@ function getMoneyRequestInformation( let isNewChatReport = false; let chatReport = !isEmptyObject(parentChatReport) && parentChatReport?.reportID ? parentChatReport : null; + const allReports = ReportConnection.getAllReports(); // If this is a policyExpenseChat, the chatReport must exist and we can get it from Onyx. // report is null if the flow is initiated from the global create menu. However, participant always stores the reportID if it exists, which is the case for policyExpenseChats if (!chatReport && isPolicyExpenseChat) { @@ -2131,7 +2130,7 @@ function getTrackExpenseInformation( // STEP 1: Get existing chat report let chatReport = !isEmptyObject(parentChatReport) && parentChatReport?.reportID ? parentChatReport : null; - + const allReports = ReportConnection.getAllReports(); // The chatReport always exists, and we can get it from Onyx if chatReport is null. if (!chatReport) { chatReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${participant.reportID}`] ?? null; @@ -2441,7 +2440,7 @@ function calculateAmountForUpdatedWaypoint( ) { let updatedAmount: number = CONST.IOU.DEFAULT_AMOUNT; let updatedMerchant = Localize.translateLocal('iou.fieldPending'); - if (!isEmptyObject(transactionChanges?.routes)) { + if (!isEmptyObject(transactionChanges?.routes?.route0?.geometry)) { const customUnitRateID = TransactionUtils.getRateID(transaction) ?? ''; const mileageRates = DistanceRequestUtils.getMileageRates(policy, true); const policyCurrency = policy?.outputCurrency ?? PolicyUtils.getPersonalPolicy()?.outputCurrency ?? CONST.CURRENCY.USD; @@ -2490,6 +2489,7 @@ function getUpdateMoneyRequestParams( const clearedPendingFields = Object.fromEntries(Object.keys(transactionChanges).map((key) => [key, null])); const errorFields = Object.fromEntries(Object.keys(pendingFields).map((key) => [key, {[DateUtils.getMicroseconds()]: Localize.translateLocal('iou.error.genericEditFailureMessage')}])); + const allReports = ReportConnection.getAllReports(); // Step 2: Get all the collections being updated const transactionThread = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`] ?? null; const transaction = allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]; @@ -2556,6 +2556,22 @@ function getUpdateMoneyRequestParams( [updatedReportAction.reportActionID]: updatedReportAction as OnyxTypes.ReportAction, }, }); + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${transactionThread?.reportID}`, + value: { + lastVisibleActionCreated: updatedReportAction.created, + lastReadTime: updatedReportAction.created, + }, + }); + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${transactionThread?.reportID}`, + value: { + lastVisibleActionCreated: transactionThread?.lastVisibleActionCreated, + lastReadTime: transactionThread?.lastReadTime, + }, + }); successData.push({ onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThread?.reportID}`, @@ -2778,6 +2794,7 @@ function getUpdateTrackExpenseParams( const clearedPendingFields = Object.fromEntries(Object.keys(transactionChanges).map((key) => [key, null])); const errorFields = Object.fromEntries(Object.keys(pendingFields).map((key) => [key, {[DateUtils.getMicroseconds()]: Localize.translateLocal('iou.error.genericEditFailureMessage')}])); + const allReports = ReportConnection.getAllReports(); // Step 2: Get all the collections being updated const transactionThread = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`] ?? null; const transaction = allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]; @@ -2944,6 +2961,7 @@ function updateMoneyRequestDate( const transactionChanges: TransactionChanges = { created: value, }; + const allReports = ReportConnection.getAllReports(); const transactionThreadReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`] ?? null; const parentReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReport?.parentReportID}`] ?? null; let data: UpdateMoneyRequestData; @@ -2984,6 +3002,7 @@ function updateMoneyRequestMerchant( const transactionChanges: TransactionChanges = { merchant: value, }; + const allReports = ReportConnection.getAllReports(); const transactionThreadReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`] ?? null; const parentReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReport?.parentReportID}`] ?? null; let data: UpdateMoneyRequestData; @@ -3072,6 +3091,7 @@ function updateMoneyRequestDistance({ waypoints, routes, }; + const allReports = ReportConnection.getAllReports(); const transactionThreadReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`] ?? null; const parentReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReport?.parentReportID}`] ?? null; let data: UpdateMoneyRequestData; @@ -3112,6 +3132,7 @@ function updateMoneyRequestDescription( const transactionChanges: TransactionChanges = { comment, }; + const allReports = ReportConnection.getAllReports(); const transactionThreadReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`] ?? null; const parentReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReport?.parentReportID}`] ?? null; let data: UpdateMoneyRequestData; @@ -3626,6 +3647,9 @@ function trackExpense( const moneyRequestReportID = isMoneyRequestReport ? report.reportID : ''; const isMovingTransactionFromTrackExpense = IOUUtils.isMovingTransactionFromTrackExpense(action); + // Pass an open receipt so the distance expense will show a map with the route optimistically + const trackedReceipt = validWaypoints ? {source: ReceiptGeneric as ReceiptSource, state: CONST.IOU.RECEIPT_STATE.OPEN} : receipt; + const { createdWorkspaceParams, iouReport, @@ -3648,7 +3672,7 @@ function trackExpense( currency, created, merchant, - receipt, + trackedReceipt, category, tag, taxCode, @@ -3694,7 +3718,7 @@ function trackExpense( taxCode, taxAmount, billable, - receipt, + trackedReceipt, createdWorkspaceParams, ); break; @@ -3725,7 +3749,7 @@ function trackExpense( taxCode, taxAmount, billable, - receipt, + trackedReceipt, createdWorkspaceParams, ); break; @@ -3744,8 +3768,8 @@ function trackExpense( createdChatReportActionID: createdChatReportActionID ?? '-1', createdIOUReportActionID, reportPreviewReportActionID: reportPreviewAction?.reportActionID, - receipt, - receiptState: receipt?.state, + receipt: trackedReceipt, + receiptState: trackedReceipt?.state, category, tag, taxCode, @@ -3777,7 +3801,7 @@ function getOrCreateOptimisticSplitChatReport(existingSplitChatReportID: string, const existingChatReportID = existingSplitChatReportID || participants[0].reportID; // Check if the report is available locally if we do have one - let existingSplitChatReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${existingChatReportID}`]; + let existingSplitChatReport = ReportConnection.getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${existingChatReportID}`]; const allParticipantsAccountIDs = [...participantAccountIDs, currentUserAccountID]; if (!existingSplitChatReport) { @@ -4069,7 +4093,9 @@ function createSplitsAndOnyxData( } // STEP 2: Get existing IOU/Expense report and update its total OR build a new optimistic one - let oneOnOneIOUReport: OneOnOneIOUReport = oneOnOneChatReport.iouReportID ? allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${oneOnOneChatReport.iouReportID}`] : null; + let oneOnOneIOUReport: OneOnOneIOUReport = oneOnOneChatReport.iouReportID + ? ReportConnection.getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${oneOnOneChatReport.iouReportID}`] + : null; const shouldCreateNewOneOnOneIOUReport = ReportUtils.shouldCreateNewMoneyRequestReport(oneOnOneIOUReport, oneOnOneChatReport); if (!oneOnOneIOUReport || shouldCreateNewOneOnOneIOUReport) { @@ -4801,6 +4827,7 @@ function completeSplitBill(chatReportID: string, reportAction: OnyxTypes.ReportA let oneOnOneChatReport: OnyxEntry; let isNewOneOnOneChatReport = false; + const allReports = ReportConnection.getAllReports(); if (isPolicyExpenseChat) { // The workspace chat reportID is saved in the splits array when starting a split expense with a workspace oneOnOneChatReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${participant.chatReportID}`]; @@ -4958,6 +4985,7 @@ function editRegularMoneyRequest( policyTags: OnyxTypes.PolicyTagList, policyCategories: OnyxTypes.PolicyCategories, ) { + const allReports = ReportConnection.getAllReports(); // STEP 1: Get all collections we're updating const transactionThread = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`] ?? null; const transaction = allTransactions[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]; @@ -5258,6 +5286,7 @@ function updateMoneyRequestAmountAndCurrency({ taxCode, taxAmount, }; + const allReports = ReportConnection.getAllReports(); const transactionThreadReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`] ?? null; const parentReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReport?.parentReportID}`] ?? null; let data: UpdateMoneyRequestData; @@ -5271,6 +5300,7 @@ function updateMoneyRequestAmountAndCurrency({ } function deleteMoneyRequest(transactionID: string, reportAction: OnyxTypes.ReportAction, isSingleTransactionView = false) { + const allReports = ReportConnection.getAllReports(); // STEP 1: Get all collections we're updating const iouReportID = ReportActionsUtils.isMoneyRequestAction(reportAction) ? ReportActionsUtils.getOriginalMessage(reportAction)?.IOUReportID : '-1'; const iouReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${iouReportID}`] ?? null; @@ -5582,7 +5612,7 @@ function deleteMoneyRequest(transactionID: string, reportAction: OnyxTypes.Repor function deleteTrackExpense(chatReportID: string, transactionID: string, reportAction: OnyxTypes.ReportAction, isSingleTransactionView = false) { // STEP 1: Get all collections we're updating - const chatReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${chatReportID}`] ?? null; + const chatReport = ReportConnection.getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${chatReportID}`] ?? null; if (!ReportUtils.isSelfDM(chatReport)) { return deleteMoneyRequest(transactionID, reportAction, isSingleTransactionView); } @@ -6218,6 +6248,11 @@ function hasIOUToApproveOrPay(chatReport: OnyxEntry, excludedI } function approveMoneyRequest(expenseReport: OnyxEntry, full?: boolean) { + if (expenseReport?.policyID && SubscriptionUtils.shouldRestrictUserBillableActions(expenseReport.policyID)) { + Navigation.navigate(ROUTES.RESTRICTED_ACTION.getRoute(expenseReport.policyID)); + return; + } + const currentNextStep = allNextSteps[`${ONYXKEYS.COLLECTION.NEXT_STEP}${expenseReport?.reportID}`] ?? null; let total = expenseReport?.total ?? 0; const hasHeldExpenses = ReportUtils.hasHeldExpenses(expenseReport?.reportID); @@ -6350,7 +6385,101 @@ function approveMoneyRequest(expenseReport: OnyxEntry, full?: API.write(WRITE_COMMANDS.APPROVE_MONEY_REQUEST, parameters, {optimisticData, successData, failureData}); } +function unapproveExpenseReport(expenseReport: OnyxEntry) { + if (isEmptyObject(expenseReport)) { + return; + } + + const currentNextStep = allNextSteps[`${ONYXKEYS.COLLECTION.NEXT_STEP}${expenseReport.reportID}`] ?? null; + + const optimisticUnapprovedReportAction = ReportUtils.buildOptimisticUnapprovedReportAction(expenseReport.total ?? 0, expenseReport.currency ?? '', expenseReport.reportID); + const optimisticNextStep = NextStepUtils.buildNextStep(expenseReport, CONST.REPORT.STATUS_NUM.SUBMITTED); + + const optimisticReportActionData: OnyxUpdate = { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport.reportID}`, + value: { + [optimisticUnapprovedReportAction.reportActionID]: { + ...(optimisticUnapprovedReportAction as OnyxTypes.ReportAction), + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + }, + }, + }; + const optimisticIOUReportData: OnyxUpdate = { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${expenseReport.reportID}`, + value: { + ...expenseReport, + lastMessageText: ReportActionsUtils.getReportActionText(optimisticUnapprovedReportAction), + lastMessageHtml: ReportActionsUtils.getReportActionHtml(optimisticUnapprovedReportAction), + stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, + statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED, + pendingFields: { + partial: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + }, + }; + + const optimisticNextStepData: OnyxUpdate = { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.NEXT_STEP}${expenseReport.reportID}`, + value: optimisticNextStep, + }; + + const optimisticData: OnyxUpdate[] = [optimisticIOUReportData, optimisticReportActionData, optimisticNextStepData]; + + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport.reportID}`, + value: { + [optimisticUnapprovedReportAction.reportActionID]: { + pendingAction: null, + }, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${expenseReport.reportID}`, + value: { + pendingFields: { + partial: null, + }, + }, + }, + ]; + + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport.reportID}`, + value: { + [optimisticUnapprovedReportAction.reportActionID]: { + errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('iou.error.other'), + }, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.NEXT_STEP}${expenseReport.reportID}`, + value: currentNextStep, + }, + ]; + + const parameters: UnapproveExpenseReportParams = { + reportID: expenseReport.reportID, + reportActionID: optimisticUnapprovedReportAction.reportActionID, + }; + + API.write(WRITE_COMMANDS.UNAPPROVE_EXPENSE_REPORT, parameters, {optimisticData, successData, failureData}); +} + function submitReport(expenseReport: OnyxTypes.Report) { + if (expenseReport.policyID && SubscriptionUtils.shouldRestrictUserBillableActions(expenseReport.policyID)) { + Navigation.navigate(ROUTES.RESTRICTED_ACTION.getRoute(expenseReport.policyID)); + return; + } + const currentNextStep = allNextSteps[`${ONYXKEYS.COLLECTION.NEXT_STEP}${expenseReport.reportID}`] ?? null; const parentReport = getReportOrDraftReport(expenseReport.parentReportID); const policy = PolicyUtils.getPolicy(expenseReport.policyID); @@ -6581,6 +6710,11 @@ function cancelPayment(expenseReport: OnyxTypes.Report, chatReport: OnyxTypes.Re } function payMoneyRequest(paymentType: PaymentMethodType, chatReport: OnyxTypes.Report, iouReport: OnyxTypes.Report, full = true) { + if (chatReport.policyID && SubscriptionUtils.shouldRestrictUserBillableActions(chatReport.policyID)) { + Navigation.navigate(ROUTES.RESTRICTED_ACTION.getRoute(chatReport.policyID)); + return; + } + const recipient = {accountID: iouReport.ownerAccountID}; const {params, optimisticData, successData, failureData} = getPayMoneyRequestParams(chatReport, iouReport, recipient, paymentType, full); @@ -6997,6 +7131,7 @@ function getIOURequestPolicyID(transaction: OnyxEntry, re export { approveMoneyRequest, + unapproveExpenseReport, canApproveIOU, canIOUBePaid, cancelPayment, 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 c5096717ecfc..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'; @@ -162,7 +162,7 @@ function addPaymentCard(params: PaymentCardParams) { const cardYear = CardUtils.getYearFromExpirationDateString(params.expirationDate); const parameters: AddPaymentCardParams = { - cardNumber: params.cardNumber, + cardNumber: CardUtils.getMCardNumberString(params.cardNumber), cardYear, cardMonth, cardCVV: params.securityCode, @@ -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/Member.ts b/src/libs/actions/Policy/Member.ts index f8472bd43098..218bf3a93c69 100644 --- a/src/libs/actions/Policy/Member.ts +++ b/src/libs/actions/Policy/Member.ts @@ -1,4 +1,3 @@ -import {ExpensiMark} from 'expensify-common'; import type {NullishDeep, OnyxCollection, OnyxCollectionInputValue, OnyxEntry, OnyxUpdate} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; import * as API from '@libs/API'; @@ -12,6 +11,7 @@ import type { import {READ_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; import * as ErrorUtils from '@libs/ErrorUtils'; import Log from '@libs/Log'; +import Parser from '@libs/Parser'; import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils'; import * as PhoneNumber from '@libs/PhoneNumber'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; @@ -627,7 +627,7 @@ function addMembersToWorkspace(invitedEmailsToAccountIDs: InvitedEmailsToAccount const params: AddMembersToWorkspaceParams = { employees: JSON.stringify(logins.map((login) => ({email: login}))), - welcomeNote: new ExpensiMark().replace(welcomeNote), + welcomeNote: Parser.replace(welcomeNote), policyID, }; if (!isEmptyObject(membersChats.reportCreationData)) { diff --git a/src/libs/actions/Policy/Policy.ts b/src/libs/actions/Policy/Policy.ts index 1bb53fbfa002..e5ebe2281a94 100644 --- a/src/libs/actions/Policy/Policy.ts +++ b/src/libs/actions/Policy/Policy.ts @@ -18,12 +18,16 @@ import type { EnablePolicyWorkflowsParams, LeavePolicyParams, OpenDraftWorkspaceRequestParams, + OpenPolicyExpensifyCardsPageParams, + OpenPolicyInitialPageParams, OpenPolicyMoreFeaturesPageParams, + OpenPolicyProfilePageParams, OpenPolicyTaxesPageParams, OpenPolicyWorkflowsPageParams, OpenWorkspaceInvitePageParams, OpenWorkspaceParams, OpenWorkspaceReimburseViewParams, + RequestExpensifyCardLimitIncreaseParams, SetWorkspaceApprovalModeParams, SetWorkspaceAutoReportingFrequencyParams, SetWorkspaceAutoReportingMonthlyOffsetParams, @@ -33,6 +37,7 @@ import type { UpdateWorkspaceCustomUnitAndRateParams, UpdateWorkspaceDescriptionParams, UpdateWorkspaceGeneralSettingsParams, + UpgradeToCorporateParams, } from '@libs/API/parameters'; import type UpdatePolicyAddressParams from '@libs/API/parameters/UpdatePolicyAddressParams'; import {READ_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; @@ -46,6 +51,7 @@ import * as PhoneNumber from '@libs/PhoneNumber'; import * as PolicyUtils from '@libs/PolicyUtils'; import {navigateWhenEnableFeature} from '@libs/PolicyUtils'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; +import * as ReportConnection from '@libs/ReportConnection'; import * as ReportUtils from '@libs/ReportUtils'; import * as TransactionUtils from '@libs/TransactionUtils'; import type {PolicySelector} from '@pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover'; @@ -120,13 +126,6 @@ Onyx.connect({ }, }); -let allReports: OnyxCollection; -Onyx.connect({ - key: ONYXKEYS.COLLECTION.REPORT, - waitForCollectionCallback: true, - callback: (value) => (allReports = value), -}); - let lastAccessedWorkspacePolicyID: OnyxEntry; Onyx.connect({ key: ONYXKEYS.LAST_ACCESSED_WORKSPACE_POLICY_ID, @@ -254,7 +253,7 @@ function deleteWorkspace(policyID: string, policyName: string) { : []), ]; - const reportsToArchive = Object.values(allReports ?? {}).filter( + const reportsToArchive = Object.values(ReportConnection.getAllReports() ?? {}).filter( (report) => report?.policyID === policyID && (ReportUtils.isChatRoom(report) || ReportUtils.isPolicyExpenseChat(report) || ReportUtils.isTaskReport(report)), ); const finallyData: OnyxUpdate[] = []; @@ -531,6 +530,14 @@ 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 clearSageIntacctErrorField(policyID: string, fieldName: string) { + Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {connections: {intacct: {config: {errorFields: {[fieldName]: null}}}}}); +} + function setWorkspaceReimbursement(policyID: string, reimbursementChoice: ValueOf, reimburserEmail: string) { const policy = getPolicy(policyID); @@ -1927,6 +1934,17 @@ function openPolicyTaxesPage(policyID: string) { API.read(READ_COMMANDS.OPEN_POLICY_TAXES_PAGE, params); } +function openPolicyExpensifyCardsPage(policyID: string) { + const authToken = NetworkStore.getAuthToken(); + + const params: OpenPolicyExpensifyCardsPageParams = { + policyID, + authToken, + }; + + API.read(READ_COMMANDS.OPEN_POLICY_EXPENSIFY_CARDS_PAGE, params); +} + function openWorkspaceInvitePage(policyID: string, clientMemberEmails: string[]) { if (!policyID || !clientMemberEmails) { Log.warn('openWorkspaceInvitePage invalid params', {policyID, clientMemberEmails}); @@ -1947,6 +1965,17 @@ function openDraftWorkspaceRequest(policyID: string) { API.read(READ_COMMANDS.OPEN_DRAFT_WORKSPACE_REQUEST, params); } +function requestExpensifyCardLimitIncrease(settlementBankAccountID: string) { + const authToken = NetworkStore.getAuthToken(); + + const params: RequestExpensifyCardLimitIncreaseParams = { + authToken, + settlementBankAccountID, + }; + + API.write(WRITE_COMMANDS.REQUEST_EXPENSIFY_CARD_LIMIT_INCREASE, params); +} + function setWorkspaceInviteMessageDraft(policyID: string, message: string | null) { Onyx.set(`${ONYXKEYS.COLLECTION.WORKSPACE_INVITE_MESSAGE_DRAFT}${policyID}`, message); } @@ -2821,6 +2850,18 @@ function openPolicyMoreFeaturesPage(policyID: string) { API.read(READ_COMMANDS.OPEN_POLICY_MORE_FEATURES_PAGE, params); } +function openPolicyProfilePage(policyID: string) { + const params: OpenPolicyProfilePageParams = {policyID}; + + API.read(READ_COMMANDS.OPEN_POLICY_PROFILE_PAGE, params); +} + +function openPolicyInitialPage(policyID: string) { + const params: OpenPolicyInitialPageParams = {policyID}; + + API.read(READ_COMMANDS.OPEN_POLICY_INITIAL_PAGE, params); +} + function setPolicyCustomTaxName(policyID: string, customTaxName: string) { const policy = getPolicy(policyID); const originalCustomTaxName = policy?.taxRates?.name; @@ -2977,6 +3018,59 @@ function setForeignCurrencyDefault(policyID: string, taxCode: string) { API.write(WRITE_COMMANDS.SET_POLICY_TAXES_FOREIGN_CURRENCY_DEFAULT, parameters, onyxData); } +function upgradeToCorporate(policyID: string, featureName: string) { + const policy = getPolicy(policyID); + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `policy_${policyID}`, + value: { + isPendingUpgrade: true, + type: CONST.POLICY.TYPE.CORPORATE, + maxExpenseAge: CONST.POLICY.DEFAULT_MAX_EXPENSE_AGE, + maxExpenseAmount: CONST.POLICY.DEFAULT_MAX_EXPENSE_AMOUNT, + maxExpenseAmountNoReceipt: CONST.POLICY.DEFAULT_MAX_AMOUNT_NO_RECEIPT, + glCodes: true, + ...(PolicyUtils.isInstantSubmitEnabled(policy) && { + autoReporting: true, + autoReportingFrequency: CONST.POLICY.AUTO_REPORTING_FREQUENCIES.MANUAL, + }), + }, + }, + ]; + + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `policy_${policyID}`, + value: { + isPendingUpgrade: false, + }, + }, + ]; + + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `policy_${policyID}`, + value: { + isPendingUpgrade: false, + type: policy?.type, + maxExpenseAge: policy?.maxExpenseAge ?? null, + maxExpenseAmount: policy?.maxExpenseAmount ?? null, + maxExpenseAmountNoReceipt: policy?.maxExpenseAmountNoReceipt ?? null, + glCodes: policy?.glCodes ?? null, + autoReporting: policy?.autoReporting ?? null, + autoReportingFrequency: policy?.autoReportingFrequency ?? null, + }, + }, + ]; + + const parameters: UpgradeToCorporateParams = {policyID, featureName}; + + API.write(WRITE_COMMANDS.UPGRADE_TO_CORPORATE, parameters, {optimisticData, successData, failureData}); +} + function getPoliciesConnectedToSageIntacct(): Policy[] { return Object.values(allPolicies ?? {}).filter((policy): policy is Policy => !!policy && !!policy?.connections?.intacct); } @@ -3028,10 +3122,13 @@ export { enablePolicyWorkflows, enableDistanceRequestTax, openPolicyMoreFeaturesPage, + openPolicyProfilePage, + openPolicyInitialPage, generateCustomUnitID, clearQBOErrorField, clearXeroErrorField, clearNetSuiteErrorField, + clearNetSuiteAutoSyncErrorField, clearWorkspaceReimbursementErrors, setWorkspaceCurrencyDefault, setForeignCurrencyDefault, @@ -3043,7 +3140,11 @@ export { buildPolicyData, enableExpensifyCard, createPolicyExpenseChats, + upgradeToCorporate, + openPolicyExpensifyCardsPage, + requestExpensifyCardLimitIncrease, getPoliciesConnectedToSageIntacct, + clearSageIntacctErrorField, }; export type {NewCustomUnit}; diff --git a/src/libs/actions/Policy/ReportField.ts b/src/libs/actions/Policy/ReportField.ts new file mode 100644 index 000000000000..31af3ff53c4c --- /dev/null +++ b/src/libs/actions/Policy/ReportField.ts @@ -0,0 +1,545 @@ +import cloneDeep from 'lodash/cloneDeep'; +import type {NullishDeep, OnyxCollection} from 'react-native-onyx'; +import Onyx from 'react-native-onyx'; +import * as API from '@libs/API'; +import type { + CreateWorkspaceReportFieldListValueParams, + CreateWorkspaceReportFieldParams, + EnableWorkspaceReportFieldListValueParams, + PolicyReportFieldsReplace, + RemoveWorkspaceReportFieldListValueParams, + UpdateWorkspaceReportFieldInitialValueParams, +} from '@libs/API/parameters'; +import {WRITE_COMMANDS} from '@libs/API/types'; +import * as ErrorUtils from '@libs/ErrorUtils'; +import * as ReportUtils from '@libs/ReportUtils'; +import * as WorkspaceReportFieldUtils from '@libs/WorkspaceReportFieldUtils'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {WorkspaceReportFieldForm} from '@src/types/form/WorkspaceReportFieldForm'; +import INPUT_IDS from '@src/types/form/WorkspaceReportFieldForm'; +import type {Policy, PolicyReportField, Report} from '@src/types/onyx'; +import type {OnyxValueWithOfflineFeedback} from '@src/types/onyx/OnyxCommon'; +import type {OnyxData} from '@src/types/onyx/Request'; + +let listValues: string[]; +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 = WorkspaceReportFieldUtils.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, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD}, + }, + errorFields: null, + }, + }, + ], + successData: [ + { + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + onyxMethod: Onyx.METHOD.MERGE, + value: { + fieldList: { + [fieldKey]: {pendingAction: null}, + }, + errorFields: null, + }, + }, + ], + failureData: [ + { + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + onyxMethod: Onyx.METHOD.MERGE, + value: { + fieldList: { + [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); +} + +function deleteReportFields(policyID: string, reportFieldsToUpdate: string[]) { + const policy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]; + const allReportFields = policy?.fieldList ?? {}; + + const updatedReportFields = Object.fromEntries(Object.entries(allReportFields).filter(([key]) => !reportFieldsToUpdate.includes(key))); + const optimisticReportFields = reportFieldsToUpdate.reduce>>>((acc, reportFieldKey) => { + acc[reportFieldKey] = {pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE}; + return acc; + }, {}); + + const successReportFields = reportFieldsToUpdate.reduce>((acc, reportFieldKey) => { + acc[reportFieldKey] = null; + return acc; + }, {}); + + const failureReportFields = reportFieldsToUpdate.reduce>>>((acc, reportFieldKey) => { + acc[reportFieldKey] = {pendingAction: null}; + return acc; + }, {}); + + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + fieldList: optimisticReportFields, + }, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + fieldList: successReportFields, + errorFields: null, + }, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + fieldList: failureReportFields, + errorFields: { + fieldList: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'), + }, + }, + }, + ], + }; + + const parameters: PolicyReportFieldsReplace = { + policyID, + reportFields: JSON.stringify(Object.values(updatedReportFields)), + }; + + API.write(WRITE_COMMANDS.POLICY_REPORT_FIELDS_REPLACE, parameters, onyxData); +} + +/** + * Updates the initial value of a report field. + */ +function updateReportFieldInitialValue(policyID: string, reportFieldID: string, newInitialValue: string) { + const previousFieldList = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]?.fieldList ?? {}; + const fieldKey = ReportUtils.getReportFieldKey(reportFieldID); + const updatedReportField: PolicyReportField = { + ...previousFieldList[fieldKey], + defaultValue: newInitialValue, + }; + const onyxData: OnyxData = { + optimisticData: [ + { + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + onyxMethod: Onyx.METHOD.MERGE, + value: { + fieldList: { + [fieldKey]: {...updatedReportField, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}, + }, + errorFields: null, + }, + }, + ], + successData: [ + { + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + onyxMethod: Onyx.METHOD.MERGE, + value: { + fieldList: { + [fieldKey]: {pendingAction: null}, + }, + errorFields: null, + }, + }, + ], + failureData: [ + { + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + onyxMethod: Onyx.METHOD.MERGE, + value: { + fieldList: { + [fieldKey]: {...previousFieldList[fieldKey], pendingAction: null}, + }, + errorFields: { + [fieldKey]: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('workspace.reportFields.genericFailureMessage'), + }, + }, + }, + ], + }; + const parameters: UpdateWorkspaceReportFieldInitialValueParams = { + policyID, + reportFields: JSON.stringify([updatedReportField]), + }; + + API.write(WRITE_COMMANDS.UPDATE_WORKSPACE_REPORT_FIELD_INITIAL_VALUE, parameters, onyxData); +} + +function updateReportFieldListValueEnabled(policyID: string, reportFieldID: string, valueIndexes: number[], enabled: boolean) { + const previousFieldList = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]?.fieldList ?? {}; + const fieldKey = ReportUtils.getReportFieldKey(reportFieldID); + const reportField = previousFieldList[fieldKey]; + + const updatedReportField = cloneDeep(reportField); + + valueIndexes.forEach((valueIndex) => { + updatedReportField.disabledOptions[valueIndex] = !enabled; + const shouldResetDefaultValue = !enabled && reportField.defaultValue === reportField.values[valueIndex]; + + if (shouldResetDefaultValue) { + updatedReportField.defaultValue = ''; + } + }); + + const onyxData: OnyxData = { + optimisticData: [ + { + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + onyxMethod: Onyx.METHOD.MERGE, + value: { + fieldList: { + [fieldKey]: {...updatedReportField, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}, + }, + errorFields: null, + }, + }, + ], + successData: [ + { + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + onyxMethod: Onyx.METHOD.MERGE, + value: { + fieldList: { + [fieldKey]: {pendingAction: null}, + }, + errorFields: null, + }, + }, + ], + failureData: [ + { + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + onyxMethod: Onyx.METHOD.MERGE, + value: { + fieldList: { + [fieldKey]: {...reportField, pendingAction: null}, + }, + errorFields: { + [fieldKey]: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('workspace.reportFields.genericFailureMessage'), + }, + }, + }, + ], + }; + const parameters: EnableWorkspaceReportFieldListValueParams = { + policyID, + reportFields: JSON.stringify([updatedReportField]), + }; + + API.write(WRITE_COMMANDS.ENABLE_WORKSPACE_REPORT_FIELD_LIST_VALUE, parameters, onyxData); +} + +/** + * Adds a new option to the list type report field on a workspace. + */ +function addReportFieldListValue(policyID: string, reportFieldID: string, valueName: string) { + const previousFieldList = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]?.fieldList ?? {}; + const reportFieldKey = ReportUtils.getReportFieldKey(reportFieldID); + const reportField = previousFieldList[reportFieldKey]; + const updatedReportField = cloneDeep(reportField); + + updatedReportField.values.push(valueName); + updatedReportField.disabledOptions.push(false); + + const onyxData: OnyxData = { + optimisticData: [ + { + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + onyxMethod: Onyx.METHOD.MERGE, + value: { + fieldList: { + [reportFieldKey]: { + ...updatedReportField, + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + }, + }, + errorFields: null, + }, + }, + ], + successData: [ + { + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + onyxMethod: Onyx.METHOD.MERGE, + value: { + fieldList: { + [reportFieldKey]: {pendingAction: null}, + }, + errorFields: null, + }, + }, + ], + failureData: [ + { + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + onyxMethod: Onyx.METHOD.MERGE, + value: { + fieldList: { + [reportFieldKey]: {...reportField, pendingAction: null}, + }, + errorFields: { + [reportFieldKey]: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('workspace.reportFields.genericFailureMessage'), + }, + }, + }, + ], + }; + + const parameters: CreateWorkspaceReportFieldListValueParams = { + policyID, + reportFields: JSON.stringify([updatedReportField]), + }; + + API.write(WRITE_COMMANDS.CREATE_WORKSPACE_REPORT_FIELD_LIST_VALUE, parameters, onyxData); +} + +/** + * Removes a list value from the workspace report fields. + */ +function removeReportFieldListValue(policyID: string, reportFieldID: string, valueIndexes: number[]) { + const previousFieldList = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]?.fieldList ?? {}; + const reportFieldKey = ReportUtils.getReportFieldKey(reportFieldID); + const reportField = previousFieldList[reportFieldKey]; + const updatedReportField = cloneDeep(reportField); + + valueIndexes + .sort((a, b) => b - a) + .forEach((valueIndex) => { + const shouldResetDefaultValue = reportField.defaultValue === reportField.values[valueIndex]; + + if (shouldResetDefaultValue) { + updatedReportField.defaultValue = ''; + } + + updatedReportField.values.splice(valueIndex, 1); + updatedReportField.disabledOptions.splice(valueIndex, 1); + }); + + const onyxData: OnyxData = { + optimisticData: [ + { + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + onyxMethod: Onyx.METHOD.MERGE, + value: { + fieldList: { + [reportFieldKey]: { + ...updatedReportField, + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + }, + errorFields: null, + }, + }, + ], + successData: [ + { + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + onyxMethod: Onyx.METHOD.MERGE, + value: { + fieldList: { + [reportFieldKey]: {pendingAction: null}, + }, + errorFields: null, + }, + }, + ], + failureData: [ + { + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + onyxMethod: Onyx.METHOD.MERGE, + value: { + fieldList: { + [reportFieldKey]: {...reportField, pendingAction: null}, + }, + errorFields: { + [reportFieldKey]: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('workspace.reportFields.genericFailureMessage'), + }, + }, + }, + ], + }; + + const parameters: RemoveWorkspaceReportFieldListValueParams = { + policyID, + reportFields: JSON.stringify([updatedReportField]), + }; + + API.write(WRITE_COMMANDS.REMOVE_WORKSPACE_REPORT_FIELD_LIST_VALUE, parameters, onyxData); +} + +export type {CreateReportFieldArguments}; + +export { + setInitialCreateReportFieldsForm, + createReportFieldsListValue, + renameReportFieldsListValue, + setReportFieldsListValueEnabled, + deleteReportFieldsListValue, + createReportField, + deleteReportFields, + updateReportFieldInitialValue, + updateReportFieldListValueEnabled, + addReportFieldListValue, + removeReportFieldListValue, +}; diff --git a/src/libs/actions/Policy/Tag.ts b/src/libs/actions/Policy/Tag.ts index dfb41b2d9015..2558969be2f3 100644 --- a/src/libs/actions/Policy/Tag.ts +++ b/src/libs/actions/Policy/Tag.ts @@ -363,7 +363,7 @@ function clearPolicyTagErrors(policyID: string, tagName: string, tagListIndex: n }); } -function clearPolicyTagListError(policyID: string, tagListIndex: number, errorField: string) { +function clearPolicyTagListErrorField(policyID: string, tagListIndex: number, errorField: string) { const policyTag = PolicyUtils.getTagLists(allPolicyTags?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`] ?? {})?.[tagListIndex] ?? {}; if (!policyTag.name) { @@ -379,6 +379,20 @@ function clearPolicyTagListError(policyID: string, tagListIndex: number, errorFi }); } +function clearPolicyTagListErrors(policyID: string, tagListIndex: number) { + const policyTag = PolicyUtils.getTagLists(allPolicyTags?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`] ?? {})?.[tagListIndex] ?? {}; + + if (!policyTag.name) { + return; + } + + Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`, { + [policyTag.name]: { + errors: null, + }, + }); +} + function renamePolicyTag(policyID: string, policyTag: {oldName: string; newName: string}, tagListIndex: number) { const tagList = PolicyUtils.getTagLists(allPolicyTags?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`] ?? {})?.[tagListIndex] ?? {}; const tag = tagList.tags?.[policyTag.oldName]; @@ -569,7 +583,7 @@ function renamePolicyTaglist(policyID: string, policyTagListName: {oldName: stri onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`, value: { - [newName]: {...oldPolicyTags, name: newName, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD}, + [newName]: {...oldPolicyTags, name: newName, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, errors: null}, [oldName]: null, }, }, @@ -589,12 +603,12 @@ function renamePolicyTaglist(policyID: string, policyTagListName: {oldName: stri onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`, value: { - errors: { - [oldName]: oldName, - [newName]: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('workspace.tags.genericFailureMessage'), - }, [newName]: null, - [oldName]: oldPolicyTags, + [oldName]: { + ...oldPolicyTags, + pendingAction: null, + errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('workspace.tags.genericFailureMessage'), + }, }, }, ], @@ -725,7 +739,8 @@ export { setPolicyTagsRequired, createPolicyTag, clearPolicyTagErrors, - clearPolicyTagListError, + clearPolicyTagListErrors, + clearPolicyTagListErrorField, deletePolicyTags, enablePolicyTags, openPolicyTagsPage, diff --git a/src/libs/actions/PriorityMode.ts b/src/libs/actions/PriorityMode.ts index a4561d44d5a0..beec327a2e40 100644 --- a/src/libs/actions/PriorityMode.ts +++ b/src/libs/actions/PriorityMode.ts @@ -1,11 +1,10 @@ import debounce from 'lodash/debounce'; -import type {OnyxCollection} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; import Log from '@libs/Log'; +import * as ReportConnection from '@libs/ReportConnection'; import * as ReportUtils from '@libs/ReportUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {Report} from '@src/types/onyx'; /** * This actions file is used to automatically switch a user into #focus mode when they exceed a certain number of reports. We do this primarily for performance reasons. @@ -35,18 +34,6 @@ Onyx.connect({ // eslint-disable-next-line @typescript-eslint/no-use-before-define const autoSwitchToFocusMode = debounce(tryFocusModeUpdate, 300, {leading: true}); -let allReports: OnyxCollection | undefined = {}; -Onyx.connect({ - key: ONYXKEYS.COLLECTION.REPORT, - waitForCollectionCallback: true, - callback: (reports) => { - allReports = reports; - - // Each time a new report is added we will check to see if the user should be switched - autoSwitchToFocusMode(); - }, -}); - let isLoadingReportData = true; Onyx.connect({ key: ONYXKEYS.IS_LOADING_REPORT_DATA, @@ -87,11 +74,10 @@ function resetHasReadRequiredDataFromStorage() { resolveIsReadyPromise = resolve; }); isLoadingReportData = true; - allReports = {}; } function checkRequiredData() { - if (allReports === undefined || hasTriedFocusMode === undefined || isInFocusMode === undefined || isLoadingReportData) { + if (ReportConnection.getAllReports() === undefined || hasTriedFocusMode === undefined || isInFocusMode === undefined || isLoadingReportData) { return; } @@ -112,6 +98,7 @@ function tryFocusModeUpdate() { } const validReports = []; + const allReports = ReportConnection.getAllReports(); Object.keys(allReports ?? {}).forEach((key) => { const report = allReports?.[key]; if (!report) { diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index d05f77f9c7ac..9870b561ad6f 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -1,5 +1,5 @@ import {format as timezoneFormat, utcToZonedTime} from 'date-fns-tz'; -import {ExpensiMark, Str} from 'expensify-common'; +import {Str} from 'expensify-common'; import isEmpty from 'lodash/isEmpty'; import {DeviceEventEmitter, InteractionManager, Linking} from 'react-native'; import type {NullishDeep, OnyxCollection, OnyxEntry, OnyxUpdate} from 'react-native-onyx'; @@ -58,10 +58,11 @@ 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'; -import {parseHtmlToMarkdown, parseHtmlToText} from '@libs/OnyxAwareParser'; +import Parser from '@libs/Parser'; import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils'; import * as PhoneNumber from '@libs/PhoneNumber'; import getPolicyEmployeeAccountIDs from '@libs/PolicyEmployeeListUtils'; @@ -69,6 +70,7 @@ import {extractPolicyIDFromPath} from '@libs/PolicyUtils'; import processReportIDDeeplink from '@libs/processReportIDDeeplink'; import * as Pusher from '@libs/Pusher/pusher'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; +import * as ReportConnection from '@libs/ReportConnection'; import * as ReportUtils from '@libs/ReportUtils'; import {doesReportBelongToWorkspace} from '@libs/ReportUtils'; import type {OptimisticAddCommentReportAction} from '@libs/ReportUtils'; @@ -189,20 +191,6 @@ Onyx.connect({ }, }); -const currentReportData: OnyxCollection = {}; -Onyx.connect({ - key: ONYXKEYS.COLLECTION.REPORT, - callback: (report, key) => { - if (!key || !report) { - return; - } - const reportID = CollectionUtils.extractCollectionItemID(key); - currentReportData[reportID] = report; - // eslint-disable-next-line @typescript-eslint/no-use-before-define - handleReportChanged(report); - }, -}); - let isNetworkOffline = false; let networkStatus: NetworkStatus; Onyx.connect({ @@ -241,7 +229,6 @@ Onyx.connect({ callback: (value) => (reportMetadata = value), }); -const allReports: OnyxCollection = {}; const typingWatchTimers: Record = {}; let reportIDDeeplinkedFromOldDot: string | undefined; @@ -272,6 +259,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); } @@ -481,7 +479,7 @@ function addActions(reportID: string, text = '', file?: FileObject) { lastReadTime: currentTime, }; - const report = currentReportData?.[reportID]; + const report = ReportConnection.getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; if (!isEmptyObject(report) && ReportUtils.getReportNotificationPreference(report) === CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN) { optimisticReport.notificationPreference = CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS; @@ -655,7 +653,7 @@ function updateGroupChatName(reportID: string, reportName: string) { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, value: { - reportName: currentReportData?.[reportID]?.reportName ?? null, + reportName: ReportConnection.getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]?.reportName ?? null, errors: { reportName: Localize.translateLocal('common.genericErrorMessage'), }, @@ -692,7 +690,7 @@ function updateGroupChatAvatar(reportID: string, file?: File | CustomRNImageMani onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, value: { - avatarUrl: currentReportData?.[reportID]?.avatarUrl ?? null, + avatarUrl: ReportConnection.getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]?.avatarUrl ?? null, pendingFields: { avatar: null, }, @@ -755,7 +753,7 @@ function openReport( const optimisticReport = reportActionsExist(reportID) ? {} : { - reportName: allReports?.[reportID]?.reportName ?? CONST.REPORT.DEFAULT_REPORT_NAME, + reportName: ReportConnection.getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]?.reportName ?? CONST.REPORT.DEFAULT_REPORT_NAME, }; const optimisticData: OnyxUpdate[] = [ @@ -783,7 +781,9 @@ function openReport( onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, value: { - errorFields: null, + errorFields: { + notFound: null, + }, }, }, { @@ -937,16 +937,26 @@ function openReport( } } - parameters.clientLastReadTime = currentReportData?.[reportID]?.lastReadTime ?? ''; + parameters.clientLastReadTime = ReportConnection.getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${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); } } @@ -1036,7 +1046,7 @@ function navigateToAndOpenChildReport(childReportID = '-1', parentReportAction: Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(childReportID)); } else { const participantAccountIDs = [...new Set([currentUserAccountID, Number(parentReportAction.actorAccountID)])]; - const parentReport = allReports?.[parentReportID]; + const parentReport = ReportConnection.getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${parentReportID}`]; // Threads from DMs and selfDMs don't have a chatType. All other threads inherit the chatType from their parent const childReportChatType = parentReport && ReportUtils.isSelfDM(parentReport) ? undefined : parentReport?.chatType; const newChat = ReportUtils.buildOptimisticChatReport( @@ -1102,7 +1112,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, + }, + ); } /** @@ -1147,7 +1166,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, + }, + ); } /** @@ -1210,7 +1238,9 @@ function markCommentAsUnread(reportID: string, reportActionCreated: string) { }, null); // If no action created date is provided, use the last action's from other user - const actionCreationTime = reportActionCreated || (latestReportActionFromOtherUsers?.created ?? allReports?.[reportID]?.lastVisibleActionCreated ?? DateUtils.getDBTime(0)); + const actionCreationTime = + reportActionCreated || + (latestReportActionFromOtherUsers?.created ?? ReportConnection.getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]?.lastVisibleActionCreated ?? DateUtils.getDBTime(0)); // We subtract 1 millisecond so that the lastReadTime is updated to just before a given reportAction's created date // For example, if we want to mark a report action with ID 100 and created date '2014-04-01 16:07:02.999' unread, we set the lastReadTime to '2014-04-01 16:07:02.998' @@ -1312,9 +1342,7 @@ function handleReportChanged(report: OnyxEntry) { return; } - if (allReports && report?.reportID) { - allReports[report.reportID] = report; - + if (report?.reportID) { if (ReportUtils.isConciergeChatReport(report)) { conciergeChatReportID = report.reportID; } @@ -1470,21 +1498,19 @@ function removeLinksFromHtml(html: string, links: string[]): string { * @param videoAttributeCache cache of video attributes ([videoSource]: videoAttributes) */ function handleUserDeletedLinksInHtml(newCommentText: string, originalCommentMarkdown: string, videoAttributeCache?: Record): string { - const parser = new ExpensiMark(); if (newCommentText.length > CONST.MAX_MARKUP_LENGTH) { return newCommentText; } - const htmlForNewComment = parser.replace(newCommentText, { + const htmlForNewComment = Parser.replace(newCommentText, { extras: {videoAttributeCache}, }); - const removedLinks = parser.getRemovedMarkdownLinks(originalCommentMarkdown, newCommentText); + const removedLinks = Parser.getRemovedMarkdownLinks(originalCommentMarkdown, newCommentText); return removeLinksFromHtml(htmlForNewComment, removedLinks); } /** Saves a new message for a comment. Marks the comment as edited, which will be reflected in the UI. */ function editReportComment(reportID: string, originalReportAction: OnyxEntry, textForNewComment: string, videoAttributeCache?: Record) { - const parser = new ExpensiMark(); const originalReportID = ReportUtils.getOriginalReportID(reportID, originalReportAction); if (!originalReportID || !originalReportAction) { @@ -1495,7 +1521,7 @@ function editReportComment(reportID: string, originalReportAction: OnyxEntry rule.name).filter((name) => name !== 'autolink')}; - parsedOriginalCommentHTML = parser.replace(originalCommentMarkdown, autolinkFilter); + const autolinkFilter = {filterRules: Parser.rules.map((rule) => rule.name).filter((name) => name !== 'autolink')}; + parsedOriginalCommentHTML = Parser.replace(originalCommentMarkdown, autolinkFilter); } // Delete the comment if it's empty @@ -1715,7 +1741,7 @@ function toggleSubscribeToChildReport(childReportID = '-1', parentReportAction: } } else { const participantAccountIDs = [...new Set([currentUserAccountID, Number(parentReportAction?.actorAccountID)])]; - const parentReport = allReports?.[parentReportID]; + const parentReport = ReportConnection.getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${parentReportID}`]; const newChat = ReportUtils.buildOptimisticChatReport( participantAccountIDs, ReportActionsUtils.getReportActionText(parentReportAction), @@ -2128,7 +2154,7 @@ function addPolicyReport(policyReport: ReportUtils.OptimisticChatReport) { /** Deletes a report, along with its reportActions, any linked reports, and any linked IOU report. */ function deleteReport(reportID: string) { - const report = currentReportData?.[reportID]; + const report = ReportConnection.getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; const onyxData: Record = { [`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]: null, [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`]: null, @@ -2280,7 +2306,7 @@ function shouldShowReportActionNotification(reportID: string, action: ReportActi } // We don't want to send a local notification if the user preference is daily, mute or hidden. - const notificationPreference = allReports?.[reportID]?.notificationPreference ?? CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS; + const notificationPreference = ReportConnection.getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]?.notificationPreference ?? CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS; if (notificationPreference !== CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS) { Log.info(`${tag} No notification because user preference is to be notified: ${notificationPreference}`); return false; @@ -2298,7 +2324,7 @@ function shouldShowReportActionNotification(reportID: string, action: ReportActi return false; } - const report = currentReportData?.[reportID]; + const report = ReportConnection.getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; if (!report || (report && report.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE)) { Log.info(`${tag} No notification because the report does not exist or is pending deleted`, false); return false; @@ -2332,9 +2358,10 @@ function showReportActionNotification(reportID: string, reportAction: ReportActi Log.info('[LocalNotification] Creating notification'); - const report = allReports?.[reportID] ?? null; + const localReportID = `${ONYXKEYS.COLLECTION.REPORT}${reportID}`; + const report = ReportConnection.getAllReports()?.[localReportID] ?? null; if (!report) { - Log.hmmm("[LocalNotification] couldn't show report action notification because the report wasn't found", {reportID, reportActionID: reportAction.reportActionID}); + Log.hmmm("[LocalNotification] couldn't show report action notification because the report wasn't found", {localReportID, reportActionID: reportAction.reportActionID}); return; } @@ -2554,7 +2581,17 @@ function getCurrentUserAccountID(): number { } function navigateToMostRecentReport(currentReport: OnyxEntry) { - const lastAccessedReportID = ReportUtils.findLastAccessedReport(allReports, false, undefined, false, false, reportMetadata, undefined, [], currentReport?.reportID)?.reportID; + const lastAccessedReportID = ReportUtils.findLastAccessedReport( + ReportConnection.getAllReports(), + false, + undefined, + false, + false, + reportMetadata, + undefined, + [], + currentReport?.reportID, + )?.reportID; if (lastAccessedReportID) { const lastAccessedReportRoute = ROUTES.REPORT_WITH_ID.getRoute(lastAccessedReportID ?? '-1'); @@ -2579,7 +2616,7 @@ function joinRoom(report: OnyxEntry) { } function leaveGroupChat(reportID: string) { - const report = currentReportData?.[reportID]; + const report = ReportConnection.getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; if (!report) { Log.warn('Attempting to leave Group Chat that does not existing locally'); return; @@ -2607,7 +2644,7 @@ function leaveGroupChat(reportID: string) { /** Leave a report by setting the state to submitted and closed */ function leaveRoom(reportID: string, isWorkspaceMemberLeavingWorkspaceRoom = false) { - const report = currentReportData?.[reportID]; + const report = ReportConnection.getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; if (!report) { return; @@ -2690,7 +2727,7 @@ function leaveRoom(reportID: string, isWorkspaceMemberLeavingWorkspaceRoom = fal /** Invites people to a room */ function inviteToRoom(reportID: string, inviteeEmailsToAccountIDs: InvitedEmailsToAccountIDs) { - const report = currentReportData?.[reportID]; + const report = ReportConnection.getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; if (!report) { return; } @@ -2784,7 +2821,7 @@ function inviteToRoom(reportID: string, inviteeEmailsToAccountIDs: InvitedEmails } function clearAddRoomMemberError(reportID: string, invitedAccountID: string) { - const report = currentReportData?.[reportID]; + const report = ReportConnection.getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, { pendingChatMembers: report?.pendingChatMembers?.filter((pendingChatMember) => pendingChatMember.accountID !== invitedAccountID), participants: { @@ -2846,7 +2883,7 @@ function inviteToGroupChat(reportID: string, inviteeEmailsToAccountIDs: InvitedE * Please see https://github.com/Expensify/App/blob/main/README.md#Security for more details */ function removeFromRoom(reportID: string, targetAccountIDs: number[]) { - const report = currentReportData?.[reportID]; + const report = ReportConnection.getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; if (!report) { return; } @@ -3116,6 +3153,7 @@ function completeOnboarding( lastName: string; }, adminsChatReportID?: string, + onboardingPolicyID?: string, ) { const isAccountIDOdd = AccountUtils.isAccountIDOddNumber(currentUserAccountID ?? 0); const targetEmail = isAccountIDOdd ? CONST.EMAIL.NOTIFICATIONS : CONST.EMAIL.CONCIERGE; @@ -3159,6 +3197,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( @@ -3802,4 +3841,5 @@ export { updateLoadingInitialReportAction, clearAddRoomMemberError, clearAvatarErrors, + handleReportChanged, }; diff --git a/src/libs/actions/ReportActions.ts b/src/libs/actions/ReportActions.ts index 395c99fc4b26..b3718079441f 100644 --- a/src/libs/actions/ReportActions.ts +++ b/src/libs/actions/ReportActions.ts @@ -1,10 +1,11 @@ import type {OnyxCollection} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; import * as ReportActionUtils from '@libs/ReportActionsUtils'; +import * as ReportConnection from '@libs/ReportConnection'; import * as ReportUtils from '@libs/ReportUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {Report as OnyxReportType, ReportActions} from '@src/types/onyx'; +import type {ReportActions} from '@src/types/onyx'; import type ReportAction from '@src/types/onyx/ReportAction'; import * as Report from './Report'; @@ -17,13 +18,6 @@ Onyx.connect({ callback: (value) => (allReportActions = value), }); -let allReports: OnyxCollection; -Onyx.connect({ - key: ONYXKEYS.COLLECTION.REPORT, - waitForCollectionCallback: true, - callback: (value) => (allReports = value), -}); - function clearReportActionErrors(reportID: string, reportAction: ReportAction, keys?: string[]) { const originalReportID = ReportUtils.getOriginalReportID(reportID, reportAction); @@ -85,7 +79,7 @@ function clearAllRelatedReportActionErrors(reportID: string, reportAction: Repor clearReportActionErrors(reportID, reportAction, keys); - const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; + const report = ReportConnection.getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; if (report?.parentReportID && report?.parentReportActionID && ignore !== 'parent') { const parentReportAction = ReportActionUtils.getReportAction(report.parentReportID, report.parentReportActionID); const parentErrorKeys = Object.keys(parentReportAction?.errors ?? {}).filter((err) => errorKeys.includes(err)); diff --git a/src/libs/actions/Search.ts b/src/libs/actions/Search.ts index ec45298c3910..70f7d2d5b7e0 100644 --- a/src/libs/actions/Search.ts +++ b/src/libs/actions/Search.ts @@ -2,7 +2,7 @@ import Onyx from 'react-native-onyx'; import type {OnyxUpdate} from 'react-native-onyx'; import * as API from '@libs/API'; import type {SearchParams} from '@libs/API/parameters'; -import {READ_COMMANDS} from '@libs/API/types'; +import {READ_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; import ONYXKEYS from '@src/ONYXKEYS'; import type {SearchTransaction} from '@src/types/onyx/SearchResults'; import * as Report from './Report'; @@ -15,7 +15,7 @@ Onyx.connect({ }, }); -function search({hash, query, policyIDs, offset, sortBy, sortOrder}: SearchParams) { +function getOnyxLoadingData(hash: number): {optimisticData: OnyxUpdate[]; finallyData: OnyxUpdate[]} { const optimisticData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, @@ -40,6 +40,12 @@ function search({hash, query, policyIDs, offset, sortBy, sortOrder}: SearchParam }, ]; + return {optimisticData, finallyData}; +} + +function search({hash, query, policyIDs, offset, sortBy, sortOrder}: SearchParams) { + const {optimisticData, finallyData} = getOnyxLoadingData(hash); + API.read(READ_COMMANDS.SEARCH, {hash, query, offset, policyIDs, sortBy, sortOrder}, {optimisticData, finallyData}); } @@ -61,4 +67,19 @@ function createTransactionThread(hash: number, transactionID: string, reportID: Onyx.merge(`${ONYXKEYS.COLLECTION.SNAPSHOT}${hash}`, onyxUpdate); } -export {search, createTransactionThread}; +function holdMoneyRequestOnSearch(hash: number, transactionIDList: string[], comment: string) { + const {optimisticData, finallyData} = getOnyxLoadingData(hash); + API.write(WRITE_COMMANDS.HOLD_MONEY_REQUEST_ON_SEARCH, {hash, transactionIDList, comment}, {optimisticData, finallyData}); +} + +function unholdMoneyRequestOnSearch(hash: number, transactionIDList: string[]) { + const {optimisticData, finallyData} = getOnyxLoadingData(hash); + API.write(WRITE_COMMANDS.UNHOLD_MONEY_REQUEST_ON_SEARCH, {hash, transactionIDList}, {optimisticData, finallyData}); +} + +function deleteMoneyRequestOnSearch(hash: number, transactionIDList: string[]) { + const {optimisticData, finallyData} = getOnyxLoadingData(hash); + API.write(WRITE_COMMANDS.DELETE_MONEY_REQUEST_ON_SEARCH, {hash, transactionIDList}, {optimisticData, finallyData}); +} + +export {search, createTransactionThread, deleteMoneyRequestOnSearch, holdMoneyRequestOnSearch, unholdMoneyRequestOnSearch}; 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/Task.ts b/src/libs/actions/Task.ts index 013ae698ed3f..0a7244bde1e5 100644 --- a/src/libs/actions/Task.ts +++ b/src/libs/actions/Task.ts @@ -12,6 +12,7 @@ import Navigation from '@libs/Navigation/Navigation'; import * as OptionsListUtils from '@libs/OptionsListUtils'; import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; +import * as ReportConnection from '@libs/ReportConnection'; import * as ReportUtils from '@libs/ReportUtils'; import playSound, {SOUNDS} from '@libs/Sound'; import CONST from '@src/CONST'; @@ -77,13 +78,6 @@ Onyx.connect({ }, }); -let allReports: OnyxCollection; -Onyx.connect({ - key: ONYXKEYS.COLLECTION.REPORT, - waitForCollectionCallback: true, - callback: (value) => (allReports = value), -}); - /** * Clears out the task info from the store */ @@ -909,14 +903,14 @@ function getParentReport(report: OnyxEntry): OnyxEntry { - return allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; + return ReportConnection.getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; } /** @@ -1129,7 +1123,7 @@ function canModifyTask(taskReport: OnyxEntry, sessionAccountID } function clearTaskErrors(reportID: string) { - const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; + const report = ReportConnection.getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; // Delete the task preview in the parent report if (report?.pendingFields?.createChat === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD) { diff --git a/src/libs/actions/Transaction.ts b/src/libs/actions/Transaction.ts index 3166d0dfcb8f..9b79a31ef738 100644 --- a/src/libs/actions/Transaction.ts +++ b/src/libs/actions/Transaction.ts @@ -13,7 +13,7 @@ import {buildOptimisticDismissedViolationReportAction} from '@libs/ReportUtils'; import * as TransactionUtils from '@libs/TransactionUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {PersonalDetails, RecentWaypoint, ReportAction, ReportActions, Transaction, TransactionViolation, TransactionViolations} from '@src/types/onyx'; +import type {PersonalDetails, RecentWaypoint, ReportAction, ReportActions, ReviewDuplicates, Transaction, TransactionViolation, TransactionViolations} from '@src/types/onyx'; import type {OnyxData} from '@src/types/onyx/Request'; import type {WaypointCollection} from '@src/types/onyx/Transaction'; @@ -127,7 +127,7 @@ function saveWaypoint(transactionID: string, index: string, waypoint: RecentWayp if (!recentWaypointAlreadyExists && waypoint !== null) { const clonedWaypoints = lodashClone(recentWaypoints); clonedWaypoints.unshift(waypoint); - Onyx.merge(ONYXKEYS.NVP_RECENT_WAYPOINTS, clonedWaypoints.slice(0, 5)); + Onyx.merge(ONYXKEYS.NVP_RECENT_WAYPOINTS, clonedWaypoints.slice(0, CONST.RECENT_WAYPOINTS_NUMBER)); } } @@ -358,6 +358,16 @@ function dismissDuplicateTransactionViolation(transactionIDs: string[], dissmiss failureData.push(...failureDataTransaction); failureData.push(...failureReportActions); + const successData: OnyxUpdate[] = transactionsReportActions.map((action, index) => ({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${action?.childReportID ?? '-1'}`, + value: { + [optimisticDissmidedViolationReportActions[index].reportActionID]: { + pendingAction: null, + }, + }, + })); + const params: DismissViolationParams = { name: CONST.VIOLATIONS.DUPLICATED_TRANSACTION, transactionIDList: transactionIDs.join(','), @@ -365,15 +375,14 @@ function dismissDuplicateTransactionViolation(transactionIDs: string[], dissmiss API.write(WRITE_COMMANDS.DISMISS_VIOLATION, params, { optimisticData, + successData, failureData, }); } -function setReviewDuplicatesKey(transactionID: string, transactionIDs: string[]) { +function setReviewDuplicatesKey(values: Partial) { Onyx.merge(`${ONYXKEYS.REVIEW_DUPLICATES}`, { - [transactionID]: { - duplicates: transactionIDs, - }, + ...values, }); } diff --git a/src/libs/actions/User.ts b/src/libs/actions/User.ts index fbeed3cd72e9..7acc79485f0c 100644 --- a/src/libs/actions/User.ts +++ b/src/libs/actions/User.ts @@ -1022,6 +1022,10 @@ function dismissTrackTrainingModal() { }); } +function requestRefund() { + API.write(WRITE_COMMANDS.REQUEST_REFUND, null); +} + export { clearFocusModeNotification, closeAccount, @@ -1053,4 +1057,5 @@ export { clearCustomStatus, updateDraftCustomStatus, clearDraftCustomStatus, + requestRefund, }; diff --git a/src/libs/actions/Welcome.ts b/src/libs/actions/Welcome.ts index cee4e24041f1..a90c386d02b6 100644 --- a/src/libs/actions/Welcome.ts +++ b/src/libs/actions/Welcome.ts @@ -1,5 +1,5 @@ import {NativeModules} from 'react-native'; -import type {OnyxCollection, OnyxUpdate} from 'react-native-onyx'; +import type {OnyxUpdate} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; import * as API from '@libs/API'; import {WRITE_COMMANDS} from '@libs/API/types'; @@ -9,7 +9,6 @@ import type {OnboardingPurposeType} from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type Onboarding from '@src/types/onyx/Onboarding'; -import type OnyxPolicy from '@src/types/onyx/Policy'; import type TryNewDot from '@src/types/onyx/TryNewDot'; let onboarding: Onboarding | [] | undefined; @@ -202,23 +201,6 @@ Onyx.connect({ }, }); -const allPolicies: OnyxCollection = {}; -Onyx.connect({ - key: ONYXKEYS.COLLECTION.POLICY, - callback: (val, key) => { - if (!key) { - return; - } - - if (val === null || val === undefined) { - delete allPolicies[key]; - return; - } - - allPolicies[key] = {...allPolicies[key], ...val}; - }, -}); - Onyx.connect({ key: ONYXKEYS.NVP_TRYNEWDOT, callback: (value) => { 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 7f7baca1548e..66bf482d6ef2 100644 --- a/src/libs/actions/connections/NetSuiteCommands.ts +++ b/src/libs/actions/connections/NetSuiteCommands.ts @@ -2,11 +2,12 @@ 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'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {Connections} from '@src/types/onyx/Policy'; +import type {Connections, NetSuiteCustomFormID} from '@src/types/onyx/Policy'; import type {OnyxData} from '@src/types/onyx/Request'; type SubsidiaryParam = { @@ -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,6 +283,150 @@ function updateNetSuiteSubsidiary(policyID: string, newSubsidiary: SubsidiaryPar API.write(WRITE_COMMANDS.UPDATE_NETSUITE_SUBSIDIARY, params, onyxData); } +function updateNetSuiteImportMapping( + policyID: string, + mappingName: TMappingName, + mappingValue: ValueOf, + oldMappingValue?: ValueOf, +) { + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + connections: { + netsuite: { + options: { + config: { + syncOptions: { + mapping: { + [mappingName]: mappingValue, + pendingFields: { + [mappingName]: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + }, + }, + errorFields: { + [mappingName]: null, + }, + }, + }, + }, + }, + }, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + connections: { + netsuite: { + options: { + config: { + syncOptions: { + mapping: { + [mappingName]: mappingValue, + pendingFields: { + [mappingName]: null, + }, + }, + }, + errorFields: { + [mappingName]: null, + }, + }, + }, + }, + }, + }, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + connections: { + netsuite: { + options: { + config: { + syncOptions: { + mapping: { + [mappingName]: oldMappingValue, + pendingFields: { + [mappingName]: null, + }, + }, + }, + errorFields: { + [mappingName]: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'), + }, + }, + }, + }, + }, + }, + }, + ], + }; + + 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, + }; + 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); @@ -343,8 +593,245 @@ 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); +} + +function updateNetSuiteReimbursementAccountID(policyID: string, bankAccountID: string, oldBankAccountID?: string) { + const onyxData = updateNetSuiteOnyxData(policyID, CONST.NETSUITE_CONFIG.REIMBURSEMENT_ACCOUNT_ID, bankAccountID, oldBankAccountID); + + const parameters = { + policyID, + bankAccountID, + }; + API.write(WRITE_COMMANDS.UPDATE_NETSUITE_REIMBURSEMENT_ACCOUNT_ID, parameters, onyxData); +} + +function updateNetSuiteCollectionAccount(policyID: string, bankAccountID: string, oldBankAccountID?: string) { + const onyxData = updateNetSuiteOnyxData(policyID, CONST.NETSUITE_CONFIG.COLLECTION_ACCOUNT, bankAccountID, oldBankAccountID); + + const parameters = { + policyID, + bankAccountID, + }; + API.write(WRITE_COMMANDS.UPDATE_NETSUITE_COLLECTION_ACCOUNT, parameters, onyxData); +} + +function updateNetSuiteExportReportsTo( + policyID: string, + approvalLevel: ValueOf, + oldApprovalLevel: ValueOf, +) { + const onyxData = updateNetSuiteSyncOptionsOnyxData(policyID, CONST.NETSUITE_CONFIG.SYNC_OPTIONS.EXPORT_REPORTS_TO, approvalLevel, oldApprovalLevel); + + const parameters = { + policyID, + value: approvalLevel, + }; + API.write(WRITE_COMMANDS.UPDATE_NETSUITE_EXPORT_REPORTS_TO, parameters, onyxData); +} + +function updateNetSuiteExportVendorBillsTo( + policyID: string, + approvalLevel: ValueOf, + oldApprovalLevel: ValueOf, +) { + const onyxData = updateNetSuiteSyncOptionsOnyxData(policyID, CONST.NETSUITE_CONFIG.SYNC_OPTIONS.EXPORT_VENDOR_BILLS_TO, approvalLevel, oldApprovalLevel); + + const parameters = { + policyID, + value: approvalLevel, + }; + API.write(WRITE_COMMANDS.UPDATE_NETSUITE_VENDOR_BILLS_TO, parameters, onyxData); +} + +function updateNetSuiteExportJournalsTo( + policyID: string, + approvalLevel: ValueOf, + oldApprovalLevel: ValueOf, +) { + const onyxData = updateNetSuiteSyncOptionsOnyxData(policyID, CONST.NETSUITE_CONFIG.SYNC_OPTIONS.EXPORT_JOURNALS_TO, approvalLevel, oldApprovalLevel); + + const parameters = { + policyID, + value: approvalLevel, + }; + API.write(WRITE_COMMANDS.UPDATE_NETSUITE_JOURNALS_TO, parameters, onyxData); +} + +function updateNetSuiteApprovalAccount(policyID: string, value: string, oldValue: string) { + const onyxData = updateNetSuiteOnyxData(policyID, CONST.NETSUITE_CONFIG.APPROVAL_ACCOUNT, value, oldValue); + + const parameters = { + policyID, + value, + }; + API.write(WRITE_COMMANDS.UPDATE_NETSUITE_APPROVAL_ACCOUNT, parameters, onyxData); +} + +function updateNetSuiteCustomFormIDOptions( + policyID: string, + value: string, + isReimbursable: boolean, + exportDestination: ValueOf, + oldCustomFormID?: NetSuiteCustomFormID, +) { + const customFormIDKey = isReimbursable ? CONST.NETSUITE_CONFIG.CUSTOM_FORM_ID_TYPE.REIMBURSABLE : CONST.NETSUITE_CONFIG.CUSTOM_FORM_ID_TYPE.NON_REIMBURSABLE; + const data = { + [customFormIDKey]: { + [CONST.NETSUITE_MAP_EXPORT_DESTINATION[exportDestination]]: value, + }, + }; + const oldData = { + [customFormIDKey]: oldCustomFormID?.[customFormIDKey] ?? null, + }; + const onyxData = updateNetSuiteOnyxData(policyID, CONST.NETSUITE_CONFIG.CUSTOM_FORM_ID_OPTIONS, data, oldData); + + const commandName = isReimbursable ? WRITE_COMMANDS.UPDATE_NETSUITE_CUSTOM_FORM_ID_OPTIONS_REIMBURSABLE : WRITE_COMMANDS.UPDATE_NETSUITE_CUSTOM_FORM_ID_OPTIONS_NON_REIMBURSABLE; + const parameters = { + policyID, + formType: CONST.NETSUITE_MAP_EXPORT_DESTINATION[exportDestination], + formID: value, + }; + API.write(commandName, parameters, onyxData); +} + export { + connectPolicyToNetSuite, updateNetSuiteSubsidiary, + updateNetSuiteSyncTaxConfiguration, updateNetSuiteExporter, updateNetSuiteExportDate, updateNetSuiteReimbursableExpensesExportDestination, @@ -360,4 +847,19 @@ export { updateNetSuiteProvincialTaxPostingAccount, updateNetSuiteAllowForeignCurrency, updateNetSuiteExportToNextOpenPeriod, + updateNetSuiteImportMapping, + updateNetSuiteCrossSubsidiaryCustomersConfiguration, + updateNetSuiteAutoSync, + updateNetSuiteSyncReimbursedReports, + updateNetSuiteSyncPeople, + updateNetSuiteAutoCreateEntities, + updateNetSuiteEnableNewCategories, + updateNetSuiteCustomFormIDOptionsEnabled, + updateNetSuiteReimbursementAccountID, + updateNetSuiteCollectionAccount, + updateNetSuiteExportReportsTo, + updateNetSuiteExportVendorBillsTo, + updateNetSuiteExportJournalsTo, + updateNetSuiteApprovalAccount, + updateNetSuiteCustomFormIDOptions, }; diff --git a/src/libs/actions/connections/SageIntacct.ts b/src/libs/actions/connections/SageIntacct.ts index 9f944bd17273..1058287f9f3b 100644 --- a/src/libs/actions/connections/SageIntacct.ts +++ b/src/libs/actions/connections/SageIntacct.ts @@ -1,6 +1,13 @@ +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 ConnectPolicyToSageIntacctParams from '@libs/API/parameters/ConnectPolicyToSageIntacctParams'; import {WRITE_COMMANDS} from '@libs/API/types'; +import * as ErrorUtils from '@libs/ErrorUtils'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Connections, SageIntacctConnectionsConfig} from '@src/types/onyx/Policy'; type SageIntacctCredentials = {companyID: string; userID: string; password: string}; @@ -14,4 +21,442 @@ function connectToSageIntacct(policyID: string, credentials: SageIntacctCredenti API.write(WRITE_COMMANDS.CONNECT_POLICY_TO_SAGE_INTACCT, parameters, {}); } -export default connectToSageIntacct; +function prepareOnyxDataForConfigUpdate(policyID: string, settingName: keyof SageIntacctConnectionsConfig, settingValue: string | boolean | null) { + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + connections: { + intacct: { + config: { + [settingName]: settingValue, + 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: { + intacct: { + config: { + pendingFields: { + [settingName]: null, + }, + errorFields: { + [settingName]: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'), + }, + }, + }, + }, + }, + }, + ]; + + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + connections: { + intacct: { + config: { + pendingFields: { + [settingName]: null, + }, + errorFields: { + [settingName]: null, + }, + }, + }, + }, + }, + }, + ]; + + return {optimisticData, failureData, successData}; +} + +function prepareOnyxDataForSyncUpdate(policyID: string, settingName: keyof Connections['intacct']['config']['sync'], settingValue: string | boolean) { + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + connections: { + intacct: { + config: { + sync: { + [settingName]: settingValue, + }, + 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: { + intacct: { + config: { + pendingFields: { + [settingName]: null, + }, + errorFields: { + [settingName]: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'), + }, + }, + }, + }, + }, + }, + ]; + + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + connections: { + intacct: { + config: { + pendingFields: { + [settingName]: null, + }, + errorFields: { + [settingName]: null, + }, + }, + }, + }, + }, + }, + ]; + + return {optimisticData, failureData, successData}; +} + +function prepareOnyxDataForAutoSyncUpdate(policyID: string, settingName: keyof Connections['intacct']['config']['autoSync'], settingValue: boolean) { + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + connections: { + intacct: { + config: { + autoSync: { + [settingName]: settingValue, + }, + 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: { + intacct: { + config: { + pendingFields: { + [settingName]: null, + }, + errorFields: { + [settingName]: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'), + }, + }, + }, + }, + }, + }, + ]; + + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + connections: { + intacct: { + config: { + pendingFields: { + [settingName]: null, + }, + errorFields: { + [settingName]: null, + }, + }, + }, + }, + }, + }, + ]; + + return {optimisticData, failureData, successData}; +} + +function prepareOnyxDataForExportUpdate(policyID: string, settingName: keyof Connections['intacct']['config']['export'], settingValue: string | null) { + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + connections: { + intacct: { + config: { + export: { + [settingName]: settingValue, + 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: { + intacct: { + config: { + export: { + [settingName]: settingValue, + pendingFields: { + [settingName]: null, + }, + errorFields: { + [settingName]: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'), + }, + }, + }, + }, + }, + }, + }, + ]; + + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + connections: { + intacct: { + config: { + export: { + [settingName]: settingValue, + pendingFields: { + [settingName]: null, + }, + errorFields: { + [settingName]: null, + }, + }, + }, + }, + }, + }, + }, + ]; + + return {optimisticData, failureData, successData}; +} + +function updateSageIntacctExporter(policyID: string, exporter: string) { + const {optimisticData, failureData, successData} = prepareOnyxDataForExportUpdate(policyID, CONST.SAGE_INTACCT_CONFIG.EXPORTER, exporter); + const parameters = { + policyID, + email: exporter, + }; + + API.write(WRITE_COMMANDS.UPDATE_SAGE_INTACCT_EXPORTER, parameters, {optimisticData, failureData, successData}); +} + +function updateSageIntacctExportDate(policyID: string, date: ValueOf) { + const {optimisticData, failureData, successData} = prepareOnyxDataForExportUpdate(policyID, CONST.SAGE_INTACCT_CONFIG.EXPORT_DATE, date); + const parameters = { + policyID, + value: date, + }; + + API.write(WRITE_COMMANDS.UPDATE_SAGE_INTACCT_EXPORT_DATE, parameters, {optimisticData, failureData, successData}); +} + +function updateSageIntacctReimbursableExpensesExportDestination(policyID: string, reimbursable: ValueOf) { + const {optimisticData, failureData, successData} = prepareOnyxDataForExportUpdate(policyID, CONST.SAGE_INTACCT_CONFIG.REIMBURSABLE, reimbursable); + const parameters = { + policyID, + value: reimbursable, + }; + + API.write(WRITE_COMMANDS.UPDATE_SAGE_INTACCT_REIMBURSABLE_EXPENSES_EXPORT_DESTINATION, parameters, {optimisticData, failureData, successData}); +} + +function updateSageIntacctNonreimbursableExpensesExportDestination(policyID: string, nonReimbursable: ValueOf) { + const {optimisticData, failureData, successData} = prepareOnyxDataForExportUpdate(policyID, CONST.SAGE_INTACCT_CONFIG.NON_REIMBURSABLE, nonReimbursable); + const parameters = { + policyID, + value: nonReimbursable, + }; + + API.write(WRITE_COMMANDS.UPDATE_SAGE_INTACCT_NON_REIMBURSABLE_EXPENSES_EXPORT_DESTINATION, parameters, {optimisticData, failureData, successData}); +} + +function updateSageIntacctReimbursableExpensesReportExportDefaultVendor(policyID: string, vendor: string) { + const {optimisticData, failureData, successData} = prepareOnyxDataForExportUpdate(policyID, CONST.SAGE_INTACCT_CONFIG.REIMBURSABLE_VENDOR, vendor); + const parameters = { + policyID, + vendorID: vendor, + }; + + API.write(WRITE_COMMANDS.UPDATE_SAGE_INTACCT_REIMBURSABLE_EXPENSES_REPORT_EXPORT_DEFAULT_VENDOR, parameters, {optimisticData, failureData, successData}); +} + +function updateSageIntacctNonreimbursableExpensesCreditCardChargeExportDefaultVendor(policyID: string, vendor: string) { + const {optimisticData, failureData, successData} = prepareOnyxDataForExportUpdate(policyID, CONST.SAGE_INTACCT_CONFIG.NON_REIMBURSABLE_CREDIT_CARD_VENDOR, vendor); + const parameters = { + policyID, + vendorID: vendor, + }; + + API.write(WRITE_COMMANDS.UPDATE_SAGE_INTACCT_NON_REIMBURSABLE_EXPENSES_CREDIT_CARD_CHARGE_EXPORT_DEFAULT_VENDOR, parameters, {optimisticData, failureData, successData}); +} + +function updateSageIntacctNonreimbursableExpensesExportAccount(policyID: string, nonReimbursableAccount: string) { + const {optimisticData, failureData, successData} = prepareOnyxDataForExportUpdate(policyID, CONST.SAGE_INTACCT_CONFIG.NON_REIMBURSABLE_ACCOUNT, nonReimbursableAccount); + const parameters = { + policyID, + creditCardAccountID: nonReimbursableAccount, + }; + + API.write(WRITE_COMMANDS.UPDATE_SAGE_INTACCT_NON_REIMBURSABLE_EXPENSES_EXPORT_ACCOUNT, parameters, {optimisticData, failureData, successData}); +} + +function updateSageIntacctNonreimbursableExpensesExportVendor(policyID: string, vendor: string) { + const {optimisticData, failureData, successData} = prepareOnyxDataForExportUpdate(policyID, CONST.SAGE_INTACCT_CONFIG.NON_REIMBURSABLE_VENDOR, vendor); + const parameters = { + policyID, + vendorID: vendor, + }; + + API.write(WRITE_COMMANDS.UPDATE_SAGE_INTACCT_NON_REIMBURSABLE_EXPENSES_EXPORT_VENDOR, parameters, {optimisticData, failureData, successData}); +} + +function updateSageIntacctDefaultVendor(policyID: string, settingName: keyof Connections['intacct']['config']['export'], vendor: string) { + if (settingName === CONST.SAGE_INTACCT_CONFIG.REIMBURSABLE_VENDOR) { + updateSageIntacctReimbursableExpensesReportExportDefaultVendor(policyID, vendor); + } else if (settingName === CONST.SAGE_INTACCT_CONFIG.NON_REIMBURSABLE_CREDIT_CARD_VENDOR) { + updateSageIntacctNonreimbursableExpensesCreditCardChargeExportDefaultVendor(policyID, vendor); + } else if (settingName === CONST.SAGE_INTACCT_CONFIG.NON_REIMBURSABLE_VENDOR) { + updateSageIntacctNonreimbursableExpensesExportVendor(policyID, vendor); + } +} + +function updateSageIntacctAutoSync(policyID: string, enabled: boolean) { + const {optimisticData, failureData, successData} = prepareOnyxDataForAutoSyncUpdate(policyID, CONST.SAGE_INTACCT_CONFIG.AUTO_SYNC_ENABLED, enabled); + const parameters = { + policyID, + enabled, + }; + + API.write(WRITE_COMMANDS.UPDATE_SAGE_INTACCT_AUTO_SYNC, parameters, {optimisticData, failureData, successData}); +} + +function updateSageIntacctImportEmployees(policyID: string, enabled: boolean) { + const {optimisticData, failureData, successData} = prepareOnyxDataForConfigUpdate(policyID, CONST.SAGE_INTACCT_CONFIG.IMPORT_EMPLOYEES, enabled); + const parameters = { + policyID, + enabled, + }; + + API.write(WRITE_COMMANDS.UPDATE_SAGE_INTACCT_IMPORT_EMPLOYEES, parameters, {optimisticData, failureData, successData}); +} + +function updateSageIntacctApprovalMode(policyID: string, enabled: boolean) { + const approvalModeSettingValue = enabled ? CONST.SAGE_INTACCT.APPROVAL_MODE.APPROVAL_MANUAL : ''; + const {optimisticData, failureData, successData} = prepareOnyxDataForConfigUpdate(policyID, CONST.SAGE_INTACCT_CONFIG.APPROVAL_MODE, approvalModeSettingValue); + const parameters = { + policyID, + value: approvalModeSettingValue, + }; + + API.write(WRITE_COMMANDS.UPDATE_SAGE_INTACCT_APPROVAL_MODE, parameters, {optimisticData, failureData, successData}); +} + +function updateSageIntacctSyncReimbursedReports(policyID: string, enabled: boolean) { + const {optimisticData, failureData, successData} = prepareOnyxDataForSyncUpdate(policyID, CONST.SAGE_INTACCT_CONFIG.SYNC_REIMBURSED_REPORTS, enabled); + const parameters = { + policyID, + enabled, + }; + + API.write(WRITE_COMMANDS.UPDATE_SAGE_INTACCT_SYNC_REIMBURSED_REPORTS, parameters, {optimisticData, failureData, successData}); +} + +function updateSageIntacctSyncReimbursementAccountID(policyID: string, vendorID: string) { + const {optimisticData, failureData, successData} = prepareOnyxDataForSyncUpdate(policyID, CONST.SAGE_INTACCT_CONFIG.REIMBURSEMENT_ACCOUNT_ID, vendorID); + const parameters = { + policyID, + vendorID, + }; + + API.write(WRITE_COMMANDS.UPDATE_SAGE_INTACCT_SYNC_REIMBURSEMENT_ACCOUNT_ID, parameters, {optimisticData, failureData, successData}); +} + +export { + connectToSageIntacct, + updateSageIntacctExporter, + updateSageIntacctExportDate, + updateSageIntacctReimbursableExpensesExportDestination, + updateSageIntacctNonreimbursableExpensesExportDestination, + updateSageIntacctNonreimbursableExpensesExportAccount, + updateSageIntacctDefaultVendor, + updateSageIntacctAutoSync, + updateSageIntacctImportEmployees, + updateSageIntacctApprovalMode, + updateSageIntacctSyncReimbursedReports, + updateSageIntacctSyncReimbursementAccountID, +}; 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/markAllPolicyReportsAsRead.ts b/src/libs/markAllPolicyReportsAsRead.ts index 49001a851cf5..259a5e426d89 100644 --- a/src/libs/markAllPolicyReportsAsRead.ts +++ b/src/libs/markAllPolicyReportsAsRead.ts @@ -1,32 +1,21 @@ -import Onyx from 'react-native-onyx'; -import ONYXKEYS from '@src/ONYXKEYS'; import type {Report} from '@src/types/onyx'; import * as ReportActionFile from './actions/Report'; +import * as ReportConnection from './ReportConnection'; import * as ReportUtils from './ReportUtils'; export default function markAllPolicyReportsAsRead(policyID: string) { - const connectionID = Onyx.connect({ - key: ONYXKEYS.COLLECTION.REPORT, - waitForCollectionCallback: true, - callback: (allReports) => { - if (!allReports) { - return; - } + let delay = 0; + const allReports = ReportConnection.getAllReports() ?? {}; + Object.keys(allReports).forEach((key: string) => { + const report: Report | null | undefined = allReports[key]; + if (report?.policyID !== policyID || !ReportUtils.isUnread(report)) { + return; + } - let delay = 0; - Object.keys(allReports).forEach((key: string) => { - const report: Report | null | undefined = allReports[key]; - if (report?.policyID !== policyID || !ReportUtils.isUnread(report)) { - return; - } + setTimeout(() => { + ReportActionFile.readNewestAction(report?.reportID); + }, delay); - setTimeout(() => { - ReportActionFile.readNewestAction(report?.reportID); - }, delay); - - delay += 1000; - }); - Onyx.disconnect(connectionID); - }, + delay += 1000; }); } 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/libs/migrations/Participants.ts b/src/libs/migrations/Participants.ts index 3dbbef486d68..eccaa0662f2f 100644 --- a/src/libs/migrations/Participants.ts +++ b/src/libs/migrations/Participants.ts @@ -1,6 +1,7 @@ import Onyx from 'react-native-onyx'; import type {NullishDeep, OnyxCollection} from 'react-native-onyx'; import Log from '@libs/Log'; +import * as ReportConnection from '@libs/ReportConnection'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Report} from '@src/types/onyx'; import type {Participants} from '@src/types/onyx/Report'; @@ -11,14 +12,7 @@ type OldReportCollection = Record>; function getReports(): Promise> { return new Promise((resolve) => { - const connectionID = Onyx.connect({ - key: ONYXKEYS.COLLECTION.REPORT, - waitForCollectionCallback: true, - callback: (reports) => { - Onyx.disconnect(connectionID); - return resolve(reports); - }, - }); + resolve(ReportConnection.getAllReports()); }); } 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..a1daa938af4c 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-compiler/react-compiler, 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/EditReportFieldPage.tsx b/src/pages/EditReportFieldPage.tsx index 69f959d6545f..d489e58493d3 100644 --- a/src/pages/EditReportFieldPage.tsx +++ b/src/pages/EditReportFieldPage.tsx @@ -77,11 +77,11 @@ function EditReportFieldPage({route, policy, report}: EditReportFieldPageProps) const value = form[fieldKey]; if (isReportFieldTitle) { ReportActions.updateReportName(report.reportID, value, report.reportName ?? ''); + Navigation.goBack(); } else { ReportActions.updateReportField(report.reportID, {...reportField, value: value === '' ? null : value}, reportField); + Navigation.dismissModal(report?.reportID); } - - Navigation.dismissModal(report?.reportID); }; const handleReportFieldDelete = () => { diff --git a/src/pages/EnablePayments/EnablePayments.tsx b/src/pages/EnablePayments/EnablePayments.tsx index 8bbf4d83726b..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/EnablePayments/IdologyQuestions.tsx b/src/pages/EnablePayments/IdologyQuestions.tsx index 756965e961c8..b9b0ac4eca34 100644 --- a/src/pages/EnablePayments/IdologyQuestions.tsx +++ b/src/pages/EnablePayments/IdologyQuestions.tsx @@ -3,11 +3,14 @@ import {View} from 'react-native'; import FormProvider from '@components/Form/FormProvider'; import InputWrapper from '@components/Form/InputWrapper'; import type {FormInputErrors, FormOnyxValues} from '@components/Form/types'; +import Icon from '@components/Icon'; +import * as Expensicons from '@components/Icon/Expensicons'; import type {Choice} from '@components/RadioButtons'; import SingleChoiceQuestion from '@components/SingleChoiceQuestion'; import Text from '@components/Text'; import TextLink from '@components/TextLink'; import useLocalize from '@hooks/useLocalize'; +import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import * as BankAccounts from '@userActions/BankAccounts'; import CONST from '@src/CONST'; @@ -34,6 +37,7 @@ type Answer = { function IdologyQuestions({questions, idNumber}: IdologyQuestionsProps) { const styles = useThemeStyles(); + const theme = useTheme(); const {translate} = useLocalize(); const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0); @@ -103,13 +107,7 @@ function IdologyQuestions({questions, idNumber}: IdologyQuestionsProps) { return ( - {translate('additionalDetailsStep.helpTextIdologyQuestions')} - - {translate('additionalDetailsStep.helpLink')} - + {translate('additionalDetailsStep.helpTextIdologyQuestions')} - { - chooseAnswer(String(value)); - }} - onInputChange={() => {}} - /> + <> + { + chooseAnswer(String(value)); + }} + onInputChange={() => {}} + /> + + + + {translate('additionalDetailsStep.helpLink')} + + + ); diff --git a/src/pages/EnablePayments/PersonalInfo/PersonalInfo.tsx b/src/pages/EnablePayments/PersonalInfo/PersonalInfo.tsx index 0162fb311a19..55d369b4a2c5 100644 --- a/src/pages/EnablePayments/PersonalInfo/PersonalInfo.tsx +++ b/src/pages/EnablePayments/PersonalInfo/PersonalInfo.tsx @@ -10,6 +10,7 @@ import useSubStep from '@hooks/useSubStep'; import type {SubStepProps} from '@hooks/useSubStep/types'; import useThemeStyles from '@hooks/useThemeStyles'; import {parsePhoneNumber} from '@libs/PhoneNumber'; +import IdologyQuestions from '@pages/EnablePayments/IdologyQuestions'; import getInitialSubstepForPersonalInfo from '@pages/EnablePayments/utils/getInitialSubstepForPersonalInfo'; import getSubstepValues from '@pages/EnablePayments/utils/getSubstepValues'; import * as Wallet from '@userActions/Wallet'; @@ -41,6 +42,7 @@ const bodyContent: Array> = [FullName, DateOfB function PersonalInfoPage({walletAdditionalDetails, walletAdditionalDetailsDraft}: PersonalInfoPageProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); + const showIdologyQuestions = walletAdditionalDetails?.questions && walletAdditionalDetails?.questions.length > 0; const values = useMemo(() => getSubstepValues(PERSONAL_INFO_STEP_KEYS, walletAdditionalDetailsDraft, walletAdditionalDetails), [walletAdditionalDetails, walletAdditionalDetailsDraft]); const submit = () => { @@ -84,6 +86,10 @@ function PersonalInfoPage({walletAdditionalDetails, walletAdditionalDetailsDraft Wallet.updateCurrentStep(CONST.WALLET.STEP.ADD_BANK_ACCOUNT); return; } + if (showIdologyQuestions) { + Wallet.setAdditionalDetailsQuestions(null, ''); + return; + } prevScreen(); }; @@ -102,11 +108,18 @@ function PersonalInfoPage({walletAdditionalDetails, walletAdditionalDetailsDraft stepNames={CONST.WALLET.STEP_NAMES} /> - + {showIdologyQuestions ? ( + + ) : ( + + )} ); } diff --git a/src/pages/InviteReportParticipantsPage.tsx b/src/pages/InviteReportParticipantsPage.tsx index 78cb5dfcd991..a4d5c5518ba2 100644 --- a/src/pages/InviteReportParticipantsPage.tsx +++ b/src/pages/InviteReportParticipantsPage.tsx @@ -76,7 +76,7 @@ function InviteReportParticipantsPage({betas, personalDetails, report, didScreen setInvitePersonalDetails(inviteOptions.personalDetails); setRecentReports(inviteOptions.recentReports); setSelectedOptions(newSelectedOptions); - // eslint-disable-next-line react-hooks/exhaustive-deps -- we don't want to recalculate when selectedOptions change + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps -- we don't want to recalculate when selectedOptions change }, [personalDetails, betas, searchTerm, excludedUsers, options]); const sections = useMemo(() => { @@ -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/LogInWithShortLivedAuthTokenPage.tsx b/src/pages/LogInWithShortLivedAuthTokenPage.tsx index 47843aa434fa..8a0d71ec8d8c 100644 --- a/src/pages/LogInWithShortLivedAuthTokenPage.tsx +++ b/src/pages/LogInWithShortLivedAuthTokenPage.tsx @@ -71,7 +71,7 @@ function LogInWithShortLivedAuthTokenPage({route, account}: LogInWithShortLivedA }); } // The only dependencies of the effect are based on props.route - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps }, [route]); if (account?.isLoading) { diff --git a/src/pages/LogOutPreviousUserPage.tsx b/src/pages/LogOutPreviousUserPage.tsx index d46b13459c46..622a7db6e086 100644 --- a/src/pages/LogOutPreviousUserPage.tsx +++ b/src/pages/LogOutPreviousUserPage.tsx @@ -67,7 +67,7 @@ function LogOutPreviousUserPage({session, route, isAccountLoading}: LogOutPrevio SessionActions.signInWithShortLivedAuthToken(email, shortLivedAuthToken); } // We only want to run this effect once on mount (when the page first loads after transitioning from OldDot) - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps }, [initialURL]); useEffect(() => { @@ -86,7 +86,7 @@ function LogOutPreviousUserPage({session, route, isAccountLoading}: LogOutPrevio navigateToExitUrl(exitUrl); }); } - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps }, [initialURL, isAccountLoading]); return ; diff --git a/src/pages/NewChatConfirmPage.tsx b/src/pages/NewChatConfirmPage.tsx index 17e5708803cd..f72850c8f7bd 100644 --- a/src/pages/NewChatConfirmPage.tsx +++ b/src/pages/NewChatConfirmPage.tsx @@ -129,7 +129,7 @@ function NewChatConfirmPage({newGroupDraft, allPersonalDetails}: NewChatConfirmP FileUtils.readFileAsync(stashedLocalAvatarImage, newGroupDraft?.avatarFileName ?? '', onSuccess, onFailure, newGroupDraft?.avatarFileType ?? ''); // we only need to run this when the component re-mounted - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps }, []); return ( 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/PrivateNotes/PrivateNotesEditPage.tsx b/src/pages/PrivateNotes/PrivateNotesEditPage.tsx index 596e49c880e6..8ee260065b63 100644 --- a/src/pages/PrivateNotes/PrivateNotesEditPage.tsx +++ b/src/pages/PrivateNotes/PrivateNotesEditPage.tsx @@ -19,7 +19,7 @@ import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import Navigation from '@libs/Navigation/Navigation'; import type {PrivateNotesNavigatorParamList} from '@libs/Navigation/types'; -import {parseHtmlToMarkdown} from '@libs/OnyxAwareParser'; +import Parser from '@libs/Parser'; import * as ReportUtils from '@libs/ReportUtils'; import updateMultilineInputRange from '@libs/updateMultilineInputRange'; import type {WithReportAndPrivateNotesOrNotFoundProps} from '@pages/home/report/withReportAndPrivateNotesOrNotFound'; @@ -52,7 +52,7 @@ function PrivateNotesEditPage({route, personalDetailsList, report, session}: Pri // We need to edit the note in markdown format, but display it in HTML format const [privateNote, setPrivateNote] = useState( - () => ReportActions.getDraftPrivateNote(report.reportID).trim() || parseHtmlToMarkdown(report?.privateNotes?.[Number(route.params.accountID)]?.note ?? '').trim(), + () => ReportActions.getDraftPrivateNote(report.reportID).trim() || Parser.htmlToMarkdown(report?.privateNotes?.[Number(route.params.accountID)]?.note ?? '').trim(), ); /** @@ -93,7 +93,7 @@ function PrivateNotesEditPage({route, personalDetailsList, report, session}: Pri const originalNote = report?.privateNotes?.[Number(route.params.accountID)]?.note ?? ''; let editedNote = ''; if (privateNote.trim() !== originalNote.trim()) { - editedNote = ReportActions.handleUserDeletedLinksInHtml(privateNote.trim(), parseHtmlToMarkdown(originalNote).trim()); + editedNote = ReportActions.handleUserDeletedLinksInHtml(privateNote.trim(), Parser.htmlToMarkdown(originalNote).trim()); ReportActions.updatePrivateNotes(report.reportID, Number(route.params.accountID), editedNote); } diff --git a/src/pages/ProfilePage.tsx b/src/pages/ProfilePage.tsx index f088de064cc7..240f3307d158 100755 --- a/src/pages/ProfilePage.tsx +++ b/src/pages/ProfilePage.tsx @@ -30,6 +30,7 @@ import * as ReportUtils from '@libs/ReportUtils'; import * as UserUtils from '@libs/UserUtils'; import * as ValidationUtils from '@libs/ValidationUtils'; import type {ProfileNavigatorParamList} from '@navigation/types'; +import * as LinkActions from '@userActions/Link'; import * as PersonalDetailsActions from '@userActions/PersonalDetails'; import * as ReportActions from '@userActions/Report'; import * as SessionActions from '@userActions/Session'; @@ -79,6 +80,9 @@ function ProfilePage({route}: ProfilePageProps) { const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST); const [personalDetailsMetadata] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_METADATA); const [session] = useOnyx(ONYXKEYS.SESSION); + const [guideCalendarLink] = useOnyx(ONYXKEYS.ACCOUNT, { + selector: (account) => account?.guideCalendarLink, + }); const reportKey = useMemo(() => { const accountID = Number(route.params?.accountID ?? -1); @@ -175,6 +179,8 @@ function ProfilePage({route}: ProfilePageProps) { return result; }, [accountID, isCurrentUser, loginParams, report]); + const isConcierge = ReportUtils.isConciergeChatReport(report); + return ( @@ -276,6 +282,16 @@ function ProfilePage({route}: ProfilePageProps) { brickRoadIndicator={ReportActions.hasErrorInPrivateNotes(report) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} /> )} + {isConcierge && guideCalendarLink && ( + { + LinkActions.openExternalLink(guideCalendarLink); + })} + /> + )} {!hasAvatar && isLoading && } diff --git a/src/pages/ReimbursementAccount/ReimbursementAccountPage.tsx b/src/pages/ReimbursementAccount/ReimbursementAccountPage.tsx index 3645a0dcf52d..9bbe9f5adbc4 100644 --- a/src/pages/ReimbursementAccount/ReimbursementAccountPage.tsx +++ b/src/pages/ReimbursementAccount/ReimbursementAccountPage.tsx @@ -245,7 +245,7 @@ function ReimbursementAccountPage({ BankAccounts.setPlaidEvent(null); } fetchData(false, isStepToOpenEmpty); - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps }, []); // The empty dependency array ensures this runs only once after the component mounts. useEffect(() => { @@ -311,7 +311,7 @@ function ReimbursementAccountPage({ Navigation.navigate(ROUTES.BANK_ACCOUNT_WITH_STEP_TO_OPEN.getRoute(getRouteForCurrentStep(currentStep), policyID, backTo)); }, - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps [isOffline, reimbursementAccount, route, hasACHDataBeenLoaded, shouldShowContinueSetupButton], ); diff --git a/src/pages/ReportDetailsPage.tsx b/src/pages/ReportDetailsPage.tsx index dcbad36d1eda..c9440ee548af 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'; @@ -20,12 +21,14 @@ import PromotedActionsBar, {PromotedActions} from '@components/PromotedActionsBa import RoomHeaderAvatars from '@components/RoomHeaderAvatars'; import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; +import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; 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 +86,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), @@ -104,6 +108,7 @@ function ReportDetailsPage({policies, report, session, personalDetails}: ReportD const [isLastMemberLeavingGroupModalVisible, setIsLastMemberLeavingGroupModalVisible] = useState(false); const [isDeleteModalVisible, setIsDeleteModalVisible] = useState(false); + const [isUnapproveModalVisible, setIsUnapproveModalVisible] = useState(false); const policy = useMemo(() => policies?.[`${ONYXKEYS.COLLECTION.POLICY}${report?.policyID ?? '-1'}`], [policies, report?.policyID]); const isPolicyAdmin = useMemo(() => PolicyUtils.isPolicyAdmin(policy), [policy]); const isPolicyEmployee = useMemo(() => PolicyUtils.isPolicyEmployee(report?.policyID ?? '-1', policies), [report?.policyID, policies]); @@ -119,16 +124,19 @@ function ReportDetailsPage({policies, report, session, personalDetails}: ReportD const isInvoiceReport = useMemo(() => ReportUtils.isInvoiceReport(report), [report]); const isInvoiceRoom = useMemo(() => ReportUtils.isInvoiceRoom(report), [report]); const isTaskReport = useMemo(() => ReportUtils.isTaskReport(report), [report]); + const isSelfDM = useMemo(() => ReportUtils.isSelfDM(report), [report]); + const isTrackExpenseReport = ReportUtils.isTrackExpenseReport(report); const parentReportAction = ReportActionsUtils.getReportAction(report?.parentReportID ?? '', report?.parentReportActionID ?? ''); const isCanceledTaskReport = ReportUtils.isCanceledTaskReport(report, parentReportAction); const canEditReportDescription = useMemo(() => ReportUtils.canEditReportDescription(report, policy), [report, policy]); const shouldShowReportDescription = isChatRoom && (canEditReportDescription || report.description !== ''); const isExpenseReport = isMoneyRequestReport || isInvoiceReport || isMoneyRequest; - const isSingleTransactionView = isMoneyRequest || ReportUtils.isTrackExpenseReport(report); + const isSingleTransactionView = isMoneyRequest || isTrackExpenseReport; + const isSelfDMTrackExpenseReport = isTrackExpenseReport && ReportUtils.isSelfDM(parentReport); const shouldDisableRename = useMemo(() => ReportUtils.shouldDisableRename(report), [report]); const parentNavigationSubtitleData = ReportUtils.getParentNavigationSubtitle(report); - // eslint-disable-next-line react-hooks/exhaustive-deps -- policy is a dependency because `getChatRoomSubtitle` calls `getPolicyName` which in turn retrieves the value from the `policy` value stored in Onyx + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps -- policy is a dependency because `getChatRoomSubtitle` calls `getPolicyName` which in turn retrieves the value from the `policy` value stored in Onyx const chatRoomSubtitle = useMemo(() => ReportUtils.getChatRoomSubtitle(report), [report, policy]); const isSystemChat = useMemo(() => ReportUtils.isSystemChat(report), [report]); const isGroupChat = useMemo(() => ReportUtils.isGroupChat(report), [report]); @@ -158,8 +166,6 @@ function ReportDetailsPage({policies, report, session, personalDetails}: ReportD }, [isInvoiceReport, isMoneyRequestReport, isSingleTransactionView]); const isPrivateNotesFetchTriggered = report?.isLoadingPrivateNotes !== undefined; - const isSelfDM = useMemo(() => ReportUtils.isSelfDM(report), [report]); - const requestParentReportAction = useMemo(() => { // 2. MoneyReport case if (caseID === CASES.MONEY_REPORT) { @@ -175,7 +181,7 @@ function ReportDetailsPage({policies, report, session, personalDetails}: ReportD typeof requestParentReportAction?.actorAccountID === 'number' && typeof session?.accountID === 'number' && requestParentReportAction.actorAccountID === session?.accountID; const isDeletedParentAction = ReportActionsUtils.isDeletedAction(requestParentReportAction); - const moneyRequestReport = useMemo(() => { + const moneyRequestReport: OnyxEntry = useMemo(() => { if (caseID === CASES.MONEY_REQUEST) { return parentReport; } @@ -190,10 +196,14 @@ function ReportDetailsPage({policies, report, session, personalDetails}: ReportD report.stateNum !== CONST.REPORT.STATE_NUM.APPROVED && !ReportUtils.isClosedReport(report) && canModifyTask; - const canDeleteRequest = - isActionOwner && (ReportUtils.canAddOrDeleteTransactions(moneyRequestReport) || ReportUtils.isTrackExpenseReport(transactionThreadReport)) && !isDeletedParentAction; + const canDeleteRequest = isActionOwner && (ReportUtils.canAddOrDeleteTransactions(moneyRequestReport) || isSelfDMTrackExpenseReport) && !isDeletedParentAction; const shouldShowDeleteButton = shouldShowTaskDeleteButton || canDeleteRequest; + const canUnapproveRequest = + ReportUtils.isExpenseReport(report) && + (ReportUtils.isReportManager(report) || isPolicyAdmin) && + (ReportUtils.isReportApproved(report) || ReportUtils.isReportManuallyReimbursed(report)); + useEffect(() => { if (canDeleteRequest) { return; @@ -221,6 +231,15 @@ function ReportDetailsPage({policies, report, session, personalDetails}: ReportD Report.leaveGroupChat(report.reportID); }, [isChatRoom, isPolicyEmployee, isPolicyExpenseChat, report.reportID, report.visibility]); + const unapproveExpenseReportOrShowModal = useCallback(() => { + if (PolicyUtils.hasAccountingConnections(policy)) { + setIsUnapproveModalVisible(true); + return; + } + Navigation.dismissModal(); + IOU.unapproveExpenseReport(moneyRequestReport); + }, [moneyRequestReport, policy]); + const shouldShowLeaveButton = !isThread && (isGroupChat || (isChatRoom && ReportUtils.canLeaveChat(report, policy)) || (isPolicyExpenseChat && !report.isOwnPolicyExpenseChat && !isPolicyAdmin)); @@ -353,6 +372,16 @@ function ReportDetailsPage({policies, report, session, personalDetails}: ReportD }, }); } + + if (canUnapproveRequest) { + items.push({ + key: CONST.REPORT_DETAILS_MENU_ITEM.UNAPPROVE, + icon: Expensicons.CircularArrowBackwards, + translationKey: 'iou.unapprove', + isAnonymousAction: false, + action: () => unapproveExpenseReportOrShowModal(), + }); + } return items; }, [ isSelfDM, @@ -377,6 +406,8 @@ function ReportDetailsPage({policies, report, session, personalDetails}: ReportD isPolicyAdmin, session, leaveChat, + canUnapproveRequest, + unapproveExpenseReportOrShowModal, ]); const displayNamesWithTooltips = useMemo(() => { @@ -396,6 +427,14 @@ function ReportDetailsPage({policies, report, session, personalDetails}: ReportD /> ) : null; + const connectedIntegration = Object.values(CONST.POLICY.CONNECTIONS.NAME).find((integration) => !!policy?.connections?.[integration]); + const connectedIntegrationName = connectedIntegration ? translate('workspace.accounting.connectionName', connectedIntegration) : ''; + const unapproveWarningText = ( + + {translate('iou.headsUp')} {translate('iou.unapproveWithIntegrationWarning', connectedIntegrationName)} + + ); + const renderedAvatar = useMemo(() => { if (isMoneyRequestReport || isInvoiceReport) { return ( @@ -537,6 +576,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 +645,11 @@ function ReportDetailsPage({policies, report, session, personalDetails}: ReportD {renderedAvatar} - {isExpenseReport && nameSectionExpenseIOU} + {isExpenseReport && (!shouldShowTitleField || !titleField) && nameSectionExpenseIOU} + {isExpenseReport && shouldShowTitleField && titleField && nameSectionTitleField} + {!isExpenseReport && nameSectionGroupWorkspace} {shouldShowReportDescription && ( @@ -640,6 +722,20 @@ function ReportDetailsPage({policies, report, session, personalDetails}: ReportD danger shouldEnableNewFocusManagement /> + { + setIsUnapproveModalVisible(false); + Navigation.dismissModal(); + IOU.unapproveExpenseReport(moneyRequestReport); + }} + cancelText={translate('common.cancel')} + onCancel={() => setIsUnapproveModalVisible(false)} + prompt={unapproveWarningText} + /> ); diff --git a/src/pages/RestrictedAction/Workspace/WorkspaceAdminRestrictedAction.tsx b/src/pages/RestrictedAction/Workspace/WorkspaceAdminRestrictedAction.tsx index efc9c36de51a..b8880f372809 100644 --- a/src/pages/RestrictedAction/Workspace/WorkspaceAdminRestrictedAction.tsx +++ b/src/pages/RestrictedAction/Workspace/WorkspaceAdminRestrictedAction.tsx @@ -10,7 +10,6 @@ import ScrollView from '@components/ScrollView'; import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; -import * as Report from '@libs/actions/Report'; import Navigation from '@libs/Navigation/Navigation'; import * as PolicyUtils from '@libs/PolicyUtils'; import variables from '@styles/variables'; @@ -28,7 +27,7 @@ function WorkspaceAdminRestrictedAction({policyID}: WorkspaceAdminRestrictedActi const openAdminsReport = useCallback(() => { const reportID = `${PolicyUtils.getPolicy(policyID)?.chatReportIDAdmins}` ?? '-1'; - Report.openReport(reportID); + Navigation.closeRHPFlow(); Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(reportID)); }, [policyID]); diff --git a/src/pages/RestrictedAction/Workspace/WorkspaceOwnerRestrictedAction.tsx b/src/pages/RestrictedAction/Workspace/WorkspaceOwnerRestrictedAction.tsx index fd6d0fc717b8..52156c05a873 100644 --- a/src/pages/RestrictedAction/Workspace/WorkspaceOwnerRestrictedAction.tsx +++ b/src/pages/RestrictedAction/Workspace/WorkspaceOwnerRestrictedAction.tsx @@ -20,6 +20,7 @@ function WorkspaceOwnerRestrictedAction() { const styles = useThemeStyles(); const addPaymentCard = () => { + Navigation.closeRHPFlow(); Navigation.navigate(ROUTES.SETTINGS_SUBSCRIPTION); }; diff --git a/src/pages/RestrictedAction/Workspace/WorkspaceUserRestrictedAction.tsx b/src/pages/RestrictedAction/Workspace/WorkspaceUserRestrictedAction.tsx index 33c159446398..4d2aabd8774e 100644 --- a/src/pages/RestrictedAction/Workspace/WorkspaceUserRestrictedAction.tsx +++ b/src/pages/RestrictedAction/Workspace/WorkspaceUserRestrictedAction.tsx @@ -10,7 +10,6 @@ import ScrollView from '@components/ScrollView'; import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; -import * as Report from '@libs/actions/Report'; import Navigation from '@libs/Navigation/Navigation'; import * as ReportUtils from '@libs/ReportUtils'; import variables from '@styles/variables'; @@ -28,7 +27,7 @@ function WorkspaceUserRestrictedAction({policyID}: WorkspaceUserRestrictedAction const openPolicyExpenseReport = useCallback(() => { const reportID = ReportUtils.findPolicyExpenseChatByPolicyID(policyID)?.reportID ?? '-1'; - Report.openReport(reportID); + Navigation.closeRHPFlow(); Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(reportID)); }, [policyID]); diff --git a/src/pages/RoomDescriptionPage.tsx b/src/pages/RoomDescriptionPage.tsx index 9f8586292895..1d64ca9e1129 100644 --- a/src/pages/RoomDescriptionPage.tsx +++ b/src/pages/RoomDescriptionPage.tsx @@ -12,7 +12,7 @@ import TextInput from '@components/TextInput'; import type {BaseTextInputRef} from '@components/TextInput/BaseTextInput/types'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; -import {parseHtmlToMarkdown} from '@libs/OnyxAwareParser'; +import Parser from '@libs/Parser'; import * as ReportUtils from '@libs/ReportUtils'; import updateMultilineInputRange from '@libs/updateMultilineInputRange'; import variables from '@styles/variables'; @@ -32,7 +32,7 @@ type RoomDescriptionPageProps = { function RoomDescriptionPage({report, policies}: RoomDescriptionPageProps) { const styles = useThemeStyles(); - const [description, setDescription] = useState(() => parseHtmlToMarkdown(report?.description ?? '')); + const [description, setDescription] = useState(() => Parser.htmlToMarkdown(report?.description ?? '')); const reportDescriptionInputRef = useRef(null); const focusTimeoutRef = useRef | null>(null); const {translate} = useLocalize(); diff --git a/src/pages/RoomInvitePage.tsx b/src/pages/RoomInvitePage.tsx index afa57755ad70..39021321a713 100644 --- a/src/pages/RoomInvitePage.tsx +++ b/src/pages/RoomInvitePage.tsx @@ -62,7 +62,7 @@ function RoomInvitePage({ useEffect(() => { setSearchTerm(SearchInputManager.searchInput); - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps }, []); // Any existing participants and Expensify emails should not be eligible for invitation @@ -94,7 +94,7 @@ function RoomInvitePage({ setUserToInvite(inviteOptions.userToInvite); setInvitePersonalDetails(inviteOptions.personalDetails); setSelectedOptions(newSelectedOptions); - // eslint-disable-next-line react-hooks/exhaustive-deps -- we don't want to recalculate when selectedOptions change + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps -- we don't want to recalculate when selectedOptions change }, [betas, debouncedSearchTerm, excludedUsers, options.personalDetails]); const sections = useMemo(() => { @@ -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/RoomMembersPage.tsx b/src/pages/RoomMembersPage.tsx index 395b3244f980..514e1f462e6b 100644 --- a/src/pages/RoomMembersPage.tsx +++ b/src/pages/RoomMembersPage.tsx @@ -84,7 +84,7 @@ function RoomMembersPage({report, session, policies}: RoomMembersPageProps) { useEffect(() => { getRoomMembers(); - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps }, []); /** @@ -185,7 +185,11 @@ function RoomMembersPage({report, session, policies}: RoomMembersPageProps) { } const pendingChatMember = report?.pendingChatMembers?.findLast((member) => member.accountID === accountID.toString()); const isAdmin = !!(policy && policy.employeeList && details.login && policy.employeeList[details.login]?.role === CONST.POLICY.ROLE.ADMIN); - const isDisabled = (isPolicyExpenseChat && isAdmin) || accountID === session?.accountID || pendingChatMember?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; + const isDisabled = + (isPolicyExpenseChat && isAdmin) || + accountID === session?.accountID || + pendingChatMember?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || + details.accountID === report.ownerAccountID; result.push({ keyForList: String(accountID), diff --git a/src/pages/Search/SearchPage.tsx b/src/pages/Search/SearchPage.tsx index 7890e53f1b3c..6e734fd835d2 100644 --- a/src/pages/Search/SearchPage.tsx +++ b/src/pages/Search/SearchPage.tsx @@ -1,11 +1,8 @@ import type {StackScreenProps} from '@react-navigation/stack'; import React from 'react'; import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; -import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import * as Illustrations from '@components/Icon/Illustrations'; import ScreenWrapper from '@components/ScreenWrapper'; import Search from '@components/Search'; -import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; import Navigation from '@libs/Navigation/Navigation'; @@ -14,12 +11,10 @@ import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import type {SearchQuery} from '@src/types/onyx/SearchResults'; -import type IconAsset from '@src/types/utils/IconAsset'; type SearchPageProps = StackScreenProps; function SearchPage({route}: SearchPageProps) { - const {translate} = useLocalize(); const {isSmallScreenWidth} = useWindowDimensions(); const styles = useThemeStyles(); @@ -28,13 +23,6 @@ function SearchPage({route}: SearchPageProps) { const query = rawQuery as SearchQuery; const isValidQuery = Object.values(CONST.SEARCH.TAB).includes(query); - const headerContent: {[key in SearchQuery]: {icon: IconAsset; title: string}} = { - all: {icon: Illustrations.MoneyReceipts, title: translate('common.expenses')}, - shared: {icon: Illustrations.SendMoney, title: translate('common.shared')}, - drafts: {icon: Illustrations.Pencil, title: translate('common.drafts')}, - finished: {icon: Illustrations.CheckmarkCircle, title: translate('common.finished')}, - }; - const handleOnBackButtonPress = () => Navigation.goBack(ROUTES.SEARCH.getRoute(CONST.SEARCH.TAB.ALL)); // On small screens this page is not displayed, the configuration is in the file: src/libs/Navigation/AppNavigator/createCustomStackNavigator/index.tsx @@ -55,11 +43,6 @@ function SearchPage({route}: SearchPageProps) { onBackButtonPress={handleOnBackButtonPress} shouldShowLink={false} > - + + + ); } diff --git a/src/pages/TransactionDuplicate/DuplicateTransactionsList.tsx b/src/pages/TransactionDuplicate/DuplicateTransactionsList.tsx index 8dd610bbd0be..00b80fecf824 100644 --- a/src/pages/TransactionDuplicate/DuplicateTransactionsList.tsx +++ b/src/pages/TransactionDuplicate/DuplicateTransactionsList.tsx @@ -2,6 +2,7 @@ import React from 'react'; import type {FlatListProps, ScrollViewProps} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import FlatList from '@components/FlatList'; +import useThemeStyles from '@hooks/useThemeStyles'; import type {Transaction} from '@src/types/onyx'; import DuplicateTransactionItem from './DuplicateTransactionItem'; @@ -23,12 +24,15 @@ const maintainVisibleContentPosition: ScrollViewProps['maintainVisibleContentPos }; function DuplicateTransactionsList({transactions}: DuplicateTransactionsListProps) { + const styles = useThemeStyles(); + return ( ); } diff --git a/src/pages/TransactionDuplicate/ReviewBillable.tsx b/src/pages/TransactionDuplicate/ReviewBillable.tsx new file mode 100644 index 000000000000..1f9be45f2cf0 --- /dev/null +++ b/src/pages/TransactionDuplicate/ReviewBillable.tsx @@ -0,0 +1,54 @@ +import type {RouteProp} from '@react-navigation/native'; +import {useRoute} from '@react-navigation/native'; +import React, {useMemo} from 'react'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import ScreenWrapper from '@components/ScreenWrapper'; +import useLocalize from '@hooks/useLocalize'; +import useReviewDuplicatesNavigation from '@hooks/useReviewDuplicatesNavigation'; +import {setReviewDuplicatesKey} from '@libs/actions/Transaction'; +import type {TransactionDuplicateNavigatorParamList} from '@libs/Navigation/types'; +import * as TransactionUtils from '@libs/TransactionUtils'; +import type SCREENS from '@src/SCREENS'; +import type {FieldItemType} from './ReviewFields'; +import ReviewFields from './ReviewFields'; + +function ReviewBillable() { + const route = useRoute>(); + const {translate} = useLocalize(); + const transactionID = TransactionUtils.getTransactionID(route.params.threadReportID ?? ''); + const compareResult = TransactionUtils.compareDuplicateTransactionFields(transactionID); + const stepNames = Object.keys(compareResult.change ?? {}).map((key, index) => (index + 1).toString()); + const {currentScreenIndex, navigateToNextScreen} = useReviewDuplicatesNavigation(Object.keys(compareResult.change ?? {}), 'billable', route.params.threadReportID ?? ''); + const options = useMemo( + () => + compareResult.change.billable?.map((billable) => ({ + text: billable ? translate('common.yes') : translate('common.no'), + value: billable, + })), + [compareResult.change.billable, translate], + ); + + const onSelectRow = (data: FieldItemType) => { + if (data.value !== undefined) { + setReviewDuplicatesKey({billable: data.value as boolean}); + } + navigateToNextScreen(); + }; + + return ( + + + + + ); +} + +ReviewBillable.displayName = 'ReviewBillable'; + +export default ReviewBillable; diff --git a/src/pages/TransactionDuplicate/ReviewCategory.tsx b/src/pages/TransactionDuplicate/ReviewCategory.tsx new file mode 100644 index 000000000000..7d55de2e6a7c --- /dev/null +++ b/src/pages/TransactionDuplicate/ReviewCategory.tsx @@ -0,0 +1,58 @@ +import type {RouteProp} from '@react-navigation/native'; +import {useRoute} from '@react-navigation/native'; +import React, {useMemo} from 'react'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import ScreenWrapper from '@components/ScreenWrapper'; +import useLocalize from '@hooks/useLocalize'; +import useReviewDuplicatesNavigation from '@hooks/useReviewDuplicatesNavigation'; +import {setReviewDuplicatesKey} from '@libs/actions/Transaction'; +import type {TransactionDuplicateNavigatorParamList} from '@libs/Navigation/types'; +import * as TransactionUtils from '@libs/TransactionUtils'; +import type SCREENS from '@src/SCREENS'; +import type {FieldItemType} from './ReviewFields'; +import ReviewFields from './ReviewFields'; + +function ReviewCategory() { + const route = useRoute>(); + const {translate} = useLocalize(); + const transactionID = TransactionUtils.getTransactionID(route.params.threadReportID ?? ''); + const compareResult = TransactionUtils.compareDuplicateTransactionFields(transactionID); + const stepNames = Object.keys(compareResult.change ?? {}).map((key, index) => (index + 1).toString()); + const {currentScreenIndex, navigateToNextScreen} = useReviewDuplicatesNavigation(Object.keys(compareResult.change ?? {}), 'category', route.params.threadReportID ?? ''); + const options = useMemo( + () => + compareResult.change.category?.map((category) => + !category + ? {text: translate('violations.none'), value: undefined} + : { + text: category, + value: category, + }, + ), + [compareResult.change.category, translate], + ); + + const onSelectRow = (data: FieldItemType) => { + if (data.value !== undefined) { + setReviewDuplicatesKey({category: data.value as string}); + } + navigateToNextScreen(); + }; + + return ( + + + + + ); +} + +ReviewCategory.displayName = 'ReviewCategory'; + +export default ReviewCategory; diff --git a/src/pages/TransactionDuplicate/ReviewDescription.tsx b/src/pages/TransactionDuplicate/ReviewDescription.tsx new file mode 100644 index 000000000000..787957b0e7e2 --- /dev/null +++ b/src/pages/TransactionDuplicate/ReviewDescription.tsx @@ -0,0 +1,57 @@ +import type {RouteProp} from '@react-navigation/native'; +import {useRoute} from '@react-navigation/native'; +import React, {useMemo} from 'react'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import ScreenWrapper from '@components/ScreenWrapper'; +import useLocalize from '@hooks/useLocalize'; +import useReviewDuplicatesNavigation from '@hooks/useReviewDuplicatesNavigation'; +import {setReviewDuplicatesKey} from '@libs/actions/Transaction'; +import type {TransactionDuplicateNavigatorParamList} from '@libs/Navigation/types'; +import * as TransactionUtils from '@libs/TransactionUtils'; +import type SCREENS from '@src/SCREENS'; +import type {FieldItemType} from './ReviewFields'; +import ReviewFields from './ReviewFields'; + +function ReviewDescription() { + const route = useRoute>(); + const {translate} = useLocalize(); + const transactionID = TransactionUtils.getTransactionID(route.params.threadReportID ?? ''); + const compareResult = TransactionUtils.compareDuplicateTransactionFields(transactionID); + const stepNames = Object.keys(compareResult.change ?? {}).map((key, index) => (index + 1).toString()); + const {currentScreenIndex, navigateToNextScreen} = useReviewDuplicatesNavigation(Object.keys(compareResult.change ?? {}), 'description', route.params.threadReportID ?? ''); + const options = useMemo( + () => + compareResult.change.description?.map((description) => + !description?.comment + ? {text: translate('violations.none'), value: ''} + : { + text: description.comment, + value: description.comment, + }, + ), + [compareResult.change.description, translate], + ); + const onSelectRow = (data: FieldItemType) => { + if (data.value !== undefined) { + setReviewDuplicatesKey({description: data.value as string}); + } + navigateToNextScreen(); + }; + + return ( + + + + + ); +} + +ReviewDescription.displayName = 'ReviewDescription'; + +export default ReviewDescription; diff --git a/src/pages/TransactionDuplicate/ReviewFields.tsx b/src/pages/TransactionDuplicate/ReviewFields.tsx new file mode 100644 index 000000000000..3c513c55e817 --- /dev/null +++ b/src/pages/TransactionDuplicate/ReviewFields.tsx @@ -0,0 +1,91 @@ +import React, {useMemo} from 'react'; +import {View} from 'react-native'; +import InteractiveStepSubHeader from '@components/InteractiveStepSubHeader'; +import SelectionList from '@components/SelectionList'; +import RadioListItem from '@components/SelectionList/RadioListItem'; +import Text from '@components/Text'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import variables from '@styles/variables'; +import CONST from '@src/CONST'; + +type FieldItemType = { + text: string; + value: string | boolean; + keyForList: string; +}; + +type ReviewFieldsProps = { + /* Step Names which are displayed in stepper */ + stepNames: string[]; + + /* Label which is displyed to describe current step */ + label: string; + + /* Values to choose from */ + options: Array<{text: string; value: string | boolean | undefined}> | undefined; + + /* Current index */ + index: number; + + /* Callback to what should happen after selecting row */ + onSelectRow: (item: FieldItemType) => void; +}; + +function ReviewFields({stepNames, label, options, index, onSelectRow}: ReviewFieldsProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + + let falsyCount = 0; + const filteredOptions = options?.filter((name) => { + if (name.text !== translate('violations.none')) { + return true; + } + falsyCount++; + return falsyCount <= 1; + }); + + const sections = useMemo( + () => + filteredOptions?.map((option) => ({ + text: option.text, + keyForList: option.text, + value: option.value ?? '', + })), + [filteredOptions], + ); + + return ( + + {stepNames.length > 1 && ( + + + + )} + + + {label} + + + + ); +} + +ReviewFields.displayName = 'ReviewFields'; + +export default ReviewFields; +export type {FieldItemType}; diff --git a/src/pages/TransactionDuplicate/ReviewMerchant.tsx b/src/pages/TransactionDuplicate/ReviewMerchant.tsx new file mode 100644 index 000000000000..b4a38ac5c527 --- /dev/null +++ b/src/pages/TransactionDuplicate/ReviewMerchant.tsx @@ -0,0 +1,58 @@ +import type {RouteProp} from '@react-navigation/native'; +import {useRoute} from '@react-navigation/native'; +import React, {useMemo} from 'react'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import ScreenWrapper from '@components/ScreenWrapper'; +import useLocalize from '@hooks/useLocalize'; +import useReviewDuplicatesNavigation from '@hooks/useReviewDuplicatesNavigation'; +import {setReviewDuplicatesKey} from '@libs/actions/Transaction'; +import type {TransactionDuplicateNavigatorParamList} from '@libs/Navigation/types'; +import * as TransactionUtils from '@libs/TransactionUtils'; +import type SCREENS from '@src/SCREENS'; +import type {FieldItemType} from './ReviewFields'; +import ReviewFields from './ReviewFields'; + +function ReviewMerchant() { + const route = useRoute>(); + const {translate} = useLocalize(); + const transactionID = TransactionUtils.getTransactionID(route.params.threadReportID ?? ''); + const compareResult = TransactionUtils.compareDuplicateTransactionFields(transactionID); + const stepNames = Object.keys(compareResult.change ?? {}).map((key, index) => (index + 1).toString()); + const {currentScreenIndex, navigateToNextScreen} = useReviewDuplicatesNavigation(Object.keys(compareResult.change ?? {}), 'merchant', route.params.threadReportID ?? ''); + const options = useMemo( + () => + compareResult.change.merchant?.map((merchant) => + !merchant + ? {text: translate('violations.none'), value: undefined} + : { + text: merchant, + value: merchant, + }, + ), + [compareResult.change.merchant, translate], + ); + + const onSelectRow = (data: FieldItemType) => { + if (data.value !== undefined) { + setReviewDuplicatesKey({merchant: data.value as string}); + } + navigateToNextScreen(); + }; + + return ( + + + + + ); +} + +ReviewMerchant.displayName = 'ReviewMerchant'; + +export default ReviewMerchant; diff --git a/src/pages/TransactionDuplicate/ReviewReimbursable.tsx b/src/pages/TransactionDuplicate/ReviewReimbursable.tsx new file mode 100644 index 000000000000..1ff187213a0c --- /dev/null +++ b/src/pages/TransactionDuplicate/ReviewReimbursable.tsx @@ -0,0 +1,54 @@ +import type {RouteProp} from '@react-navigation/native'; +import {useRoute} from '@react-navigation/native'; +import React, {useMemo} from 'react'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import ScreenWrapper from '@components/ScreenWrapper'; +import useLocalize from '@hooks/useLocalize'; +import useReviewDuplicatesNavigation from '@hooks/useReviewDuplicatesNavigation'; +import {setReviewDuplicatesKey} from '@libs/actions/Transaction'; +import type {TransactionDuplicateNavigatorParamList} from '@libs/Navigation/types'; +import * as TransactionUtils from '@libs/TransactionUtils'; +import type SCREENS from '@src/SCREENS'; +import type {FieldItemType} from './ReviewFields'; +import ReviewFields from './ReviewFields'; + +function ReviewReimbursable() { + const route = useRoute>(); + const {translate} = useLocalize(); + const transactionID = TransactionUtils.getTransactionID(route.params.threadReportID ?? ''); + const compareResult = TransactionUtils.compareDuplicateTransactionFields(transactionID); + const stepNames = Object.keys(compareResult.change ?? {}).map((key, index) => (index + 1).toString()); + const {currentScreenIndex, navigateToNextScreen} = useReviewDuplicatesNavigation(Object.keys(compareResult.change ?? {}), 'reimbursable', route.params.threadReportID ?? ''); + const options = useMemo( + () => + compareResult.change.reimbursable?.map((reimbursable) => ({ + text: reimbursable ? translate('common.yes') : translate('common.no'), + value: reimbursable, + })), + [compareResult.change.reimbursable, translate], + ); + + const onSelectRow = (data: FieldItemType) => { + if (data.value !== undefined) { + setReviewDuplicatesKey({reimbursable: data.value as boolean}); + } + navigateToNextScreen(); + }; + + return ( + + + + + ); +} + +ReviewReimbursable.displayName = 'ReviewReimbursable'; + +export default ReviewReimbursable; diff --git a/src/pages/TransactionDuplicate/ReviewTag.tsx b/src/pages/TransactionDuplicate/ReviewTag.tsx new file mode 100644 index 000000000000..192434678a78 --- /dev/null +++ b/src/pages/TransactionDuplicate/ReviewTag.tsx @@ -0,0 +1,58 @@ +import type {RouteProp} from '@react-navigation/native'; +import {useRoute} from '@react-navigation/native'; +import React, {useMemo} from 'react'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import ScreenWrapper from '@components/ScreenWrapper'; +import useLocalize from '@hooks/useLocalize'; +import useReviewDuplicatesNavigation from '@hooks/useReviewDuplicatesNavigation'; +import {setReviewDuplicatesKey} from '@libs/actions/Transaction'; +import type {TransactionDuplicateNavigatorParamList} from '@libs/Navigation/types'; +import * as TransactionUtils from '@libs/TransactionUtils'; +import type SCREENS from '@src/SCREENS'; +import type {FieldItemType} from './ReviewFields'; +import ReviewFields from './ReviewFields'; + +function ReviewTag() { + const route = useRoute>(); + const {translate} = useLocalize(); + const transactionID = TransactionUtils.getTransactionID(route.params.threadReportID ?? ''); + + const compareResult = TransactionUtils.compareDuplicateTransactionFields(transactionID); + const stepNames = Object.keys(compareResult.change ?? {}).map((key, index) => (index + 1).toString()); + const {currentScreenIndex, navigateToNextScreen} = useReviewDuplicatesNavigation(Object.keys(compareResult.change ?? {}), 'tag', route.params.threadReportID ?? ''); + const options = useMemo( + () => + compareResult.change.tag?.map((tag) => + !tag + ? {text: translate('violations.none'), value: undefined} + : { + text: tag, + value: tag, + }, + ), + [compareResult.change.tag, translate], + ); + const onSelectRow = (data: FieldItemType) => { + if (data.value !== undefined) { + setReviewDuplicatesKey({tag: data.value as string}); + } + navigateToNextScreen(); + }; + + return ( + + + + + ); +} + +ReviewTag.displayName = 'ReviewTag'; + +export default ReviewTag; diff --git a/src/pages/TransactionDuplicate/ReviewTaxCode.tsx b/src/pages/TransactionDuplicate/ReviewTaxCode.tsx new file mode 100644 index 000000000000..77ca169969fc --- /dev/null +++ b/src/pages/TransactionDuplicate/ReviewTaxCode.tsx @@ -0,0 +1,64 @@ +import type {RouteProp} from '@react-navigation/native'; +import {useRoute} from '@react-navigation/native'; +import React, {useMemo} from 'react'; +import {useOnyx} from 'react-native-onyx'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import ScreenWrapper from '@components/ScreenWrapper'; +import useLocalize from '@hooks/useLocalize'; +import useReviewDuplicatesNavigation from '@hooks/useReviewDuplicatesNavigation'; +import {setReviewDuplicatesKey} from '@libs/actions/Transaction'; +import type {TransactionDuplicateNavigatorParamList} from '@libs/Navigation/types'; +import * as PolicyUtils from '@libs/PolicyUtils'; +import * as TransactionUtils from '@libs/TransactionUtils'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type SCREENS from '@src/SCREENS'; +import ReviewDescription from './ReviewDescription'; +import type {FieldItemType} from './ReviewFields'; +import ReviewFields from './ReviewFields'; + +function ReviewTaxRate() { + const route = useRoute>(); + const {translate} = useLocalize(); + const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${route.params.threadReportID}`); + const policy = PolicyUtils.getPolicy(report?.policyID ?? ''); + const transactionID = TransactionUtils.getTransactionID(route.params.threadReportID ?? ''); + const compareResult = TransactionUtils.compareDuplicateTransactionFields(transactionID); + const stepNames = Object.keys(compareResult.change ?? {}).map((key, index) => (index + 1).toString()); + const {currentScreenIndex, navigateToNextScreen} = useReviewDuplicatesNavigation(Object.keys(compareResult.change ?? {}), 'taxCode', route.params.threadReportID ?? ''); + const options = useMemo( + () => + compareResult.change.taxCode?.map((taxID) => + !taxID + ? {text: translate('violations.none'), value: undefined} + : { + text: PolicyUtils.getTaxByID(policy, taxID)?.name ?? '', + value: taxID, + }, + ), + [compareResult.change.taxCode, policy, translate], + ); + + const onSelectRow = (data: FieldItemType) => { + if (data.value !== undefined) { + setReviewDuplicatesKey({taxCode: data.value as string}); + } + navigateToNextScreen(); + }; + + return ( + + + + + ); +} + +ReviewTaxRate.displayName = 'ReviewTaxRate'; + +export default ReviewTaxRate; diff --git a/src/pages/TransactionReceiptPage.tsx b/src/pages/TransactionReceiptPage.tsx index 3d93851355c9..6129deb438de 100644 --- a/src/pages/TransactionReceiptPage.tsx +++ b/src/pages/TransactionReceiptPage.tsx @@ -43,7 +43,7 @@ function TransactionReceipt({transaction, report, reportMetadata = {isLoadingIni } ReportActions.openReport(route.params.reportID); // I'm disabling the warning, as it expects to use exhaustive deps, even though we want this useEffect to run only on the first render. - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps }, []); const moneyRequestReportID = ReportUtils.isMoneyRequestReport(report) ? report?.reportID : report?.parentReportID; diff --git a/src/pages/UnlinkLoginPage.tsx b/src/pages/UnlinkLoginPage.tsx index 326d09df3143..9869e4a70e68 100644 --- a/src/pages/UnlinkLoginPage.tsx +++ b/src/pages/UnlinkLoginPage.tsx @@ -26,7 +26,7 @@ function UnlinkLoginPage({route, account}: UnlinkLoginPageProps) { useEffect(() => { Session.unlinkLogin(Number(accountID), validateCode); // We only want this to run on mount - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps }, []); useEffect(() => { diff --git a/src/pages/ValidateLoginPage/index.tsx b/src/pages/ValidateLoginPage/index.tsx index d7e975890186..faf12194ca62 100644 --- a/src/pages/ValidateLoginPage/index.tsx +++ b/src/pages/ValidateLoginPage/index.tsx @@ -28,7 +28,7 @@ function ValidateLoginPage({ Session.signInWithValidateCodeAndNavigate(Number(accountID), validateCode, '', exitTo); } }); - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps }, []); useEffect(() => { diff --git a/src/pages/ValidateLoginPage/index.website.tsx b/src/pages/ValidateLoginPage/index.website.tsx index bcfbc25aff42..13f636867852 100644 --- a/src/pages/ValidateLoginPage/index.website.tsx +++ b/src/pages/ValidateLoginPage/index.website.tsx @@ -51,7 +51,7 @@ function ValidateLoginPage({ // we need to `popToTop` the stack after `signInWithValidateCode` in order to // perform login for both 2FA and non-2FA accounts. desktopLoginRedirect(autoAuthState, isSignedIn); - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps }, []); useEffect(() => { diff --git a/src/pages/home/HeaderView.tsx b/src/pages/home/HeaderView.tsx index dc341d39a1dd..481a497fe2a7 100644 --- a/src/pages/home/HeaderView.tsx +++ b/src/pages/home/HeaderView.tsx @@ -1,12 +1,12 @@ -import React, {memo, useMemo} from 'react'; +import React, {memo} from 'react'; import {View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import {withOnyx} from 'react-native-onyx'; +import Badge from '@components/Badge'; import Button from '@components/Button'; import CaretWrapper from '@components/CaretWrapper'; import ConfirmModal from '@components/ConfirmModal'; import DisplayNames from '@components/DisplayNames'; -import type {ThreeDotsMenuItem} from '@components/HeaderWithBackButton/types'; import Icon from '@components/Icon'; import * as Expensicons from '@components/Icon/Expensicons'; import MultipleAvatars from '@components/MultipleAvatars'; @@ -17,17 +17,14 @@ import ReportHeaderSkeletonView from '@components/ReportHeaderSkeletonView'; import SubscriptAvatar from '@components/SubscriptAvatar'; import TaskHeaderActionButton from '@components/TaskHeaderActionButton'; import Text from '@components/Text'; -import ThreeDotsMenu from '@components/ThreeDotsMenu'; import Tooltip from '@components/Tooltip'; import useLocalize from '@hooks/useLocalize'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; -import useWindowDimensions from '@hooks/useWindowDimensions'; -import * as HeaderUtils from '@libs/HeaderUtils'; import Navigation from '@libs/Navigation/Navigation'; import * as OptionsListUtils from '@libs/OptionsListUtils'; import * as ReportUtils from '@libs/ReportUtils'; -import * as Link from '@userActions/Link'; +import * as SubscriptionUtils from '@libs/SubscriptionUtils'; import * as Report from '@userActions/Report'; import * as Session from '@userActions/Session'; import * as Task from '@userActions/Task'; @@ -38,12 +35,6 @@ import type * as OnyxTypes from '@src/types/onyx'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; type HeaderViewOnyxProps = { - /** URL to the assigned guide's appointment booking calendar */ - guideCalendarLink: OnyxEntry; - - /** Current user session */ - session: OnyxEntry; - /** Personal details of all the users */ personalDetails: OnyxEntry; @@ -71,20 +62,8 @@ type HeaderViewProps = HeaderViewOnyxProps & { shouldUseNarrowLayout?: boolean; }; -function HeaderView({ - report, - personalDetails, - parentReport, - parentReportAction, - policy, - session, - reportID, - guideCalendarLink, - onNavigationMenuButtonClicked, - shouldUseNarrowLayout = false, -}: HeaderViewProps) { +function HeaderView({report, personalDetails, parentReport, parentReportAction, policy, reportID, onNavigationMenuButtonClicked, shouldUseNarrowLayout = false}: HeaderViewProps) { const [isDeleteTaskConfirmModalVisible, setIsDeleteTaskConfirmModalVisible] = React.useState(false); - const {windowWidth} = useWindowDimensions(); const {translate} = useLocalize(); const theme = useTheme(); const styles = useThemeStyles(); @@ -106,9 +85,6 @@ function HeaderView({ const title = ReportUtils.getReportName(reportHeaderData, undefined, parentReportAction); const subtitle = ReportUtils.getChatRoomSubtitle(reportHeaderData); const parentNavigationSubtitleData = ReportUtils.getParentNavigationSubtitle(reportHeaderData); - const isConcierge = ReportUtils.isConciergeChatReport(report); - const isCanceledTaskReport = ReportUtils.isCanceledTaskReport(report, parentReportAction); - const isPolicyEmployee = useMemo(() => !isEmptyObject(policy), [policy]); const reportDescription = ReportUtils.getReportDescriptionText(report); const policyName = ReportUtils.getPolicyName(report, true); const policyDescription = ReportUtils.getPolicyDescriptionText(policy); @@ -126,48 +102,9 @@ function HeaderView({ return true; }; - // We hide the button when we are chatting with an automated Expensify account since it's not possible to contact - // these users via alternative means. It is possible to request a call with Concierge so we leave the option for them. - const threeDotMenuItems: ThreeDotsMenuItem[] = []; - if (isTaskReport && !isCanceledTaskReport) { - const canModifyTask = Task.canModifyTask(report, session?.accountID ?? -1); - - // Task is marked as completed - if (ReportUtils.isCompletedTaskReport(report) && canModifyTask) { - threeDotMenuItems.push({ - icon: Expensicons.Checkmark, - text: translate('task.markAsIncomplete'), - onSelected: Session.checkIfActionIsAllowed(() => Task.reopenTask(report)), - }); - } - - // Task is not closed - if (ReportUtils.canWriteInReport(report) && report.stateNum !== CONST.REPORT.STATE_NUM.APPROVED && !ReportUtils.isClosedReport(report) && canModifyTask) { - threeDotMenuItems.push({ - icon: Expensicons.Trashcan, - text: translate('common.delete'), - onSelected: Session.checkIfActionIsAllowed(() => setIsDeleteTaskConfirmModalVisible(true)), - }); - } - } - const join = Session.checkIfActionIsAllowed(() => Report.joinRoom(report)); const canJoin = ReportUtils.canJoinChat(report, parentReportAction, policy); - if (canJoin) { - threeDotMenuItems.push({ - icon: Expensicons.ChatBubbles, - text: translate('common.join'), - onSelected: join, - }); - } else if (ReportUtils.canLeaveChat(report, policy)) { - const isWorkspaceMemberLeavingWorkspaceRoom = !isChatThread && (report.visibility === CONST.REPORT.VISIBILITY.RESTRICTED || isPolicyExpenseChat) && isPolicyEmployee; - threeDotMenuItems.push({ - icon: Expensicons.ChatBubbles, - text: translate('common.leave'), - onSelected: Session.checkIfActionIsAllowed(() => Report.leaveRoom(reportID, isWorkspaceMemberLeavingWorkspaceRoom)), - }); - } const joinButton = (