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/bumpVersion/index.js b/.github/actions/javascript/bumpVersion/index.js index 93ea47bed2ae..c8360931845a 100644 --- a/.github/actions/javascript/bumpVersion/index.js +++ b/.github/actions/javascript/bumpVersion/index.js @@ -1928,7 +1928,7 @@ class SemVer { do { const a = this.build[i] const b = other.build[i] - debug('prerelease compare', i, a, b) + debug('build compare', i, a, b) if (a === undefined && b === undefined) { return 0 } else if (b === undefined) { 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/deploy.yml b/.github/workflows/deploy.yml index 624c00de6831..5030ea1c2f2b 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -28,6 +28,24 @@ jobs: - name: 🚀 Push tags to trigger staging deploy 🚀 run: git push --tags + + - name: Warn deployers if staging deploy failed + if: ${{ failure() }} + uses: 8398a7/action-slack@v3 + with: + status: custom + custom_payload: | + { + channel: '#deployer', + attachments: [{ + color: "#DB4545", + pretext: ``, + text: `💥 NewDot staging deploy failed. 💥`, + }] + } + env: + GITHUB_TOKEN: ${{ github.token }} + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }} deployProduction: runs-on: ubuntu-latest @@ -65,6 +83,24 @@ jobs: PR_LIST: ${{ steps.getReleasePRList.outputs.PR_LIST }} - name: 🚀 Create release to trigger production deploy 🚀 - run: gh release create ${{ env.PRODUCTION_VERSION }} --generate-notes + run: gh release create ${{ env.PRODUCTION_VERSION }} --notes '${{ steps.getReleaseBody.outputs.RELEASE_BODY }}' env: GITHUB_TOKEN: ${{ steps.setupGitForOSBotify.outputs.OS_BOTIFY_API_TOKEN }} + + - name: Warn deployers if production deploy failed + if: ${{ failure() }} + uses: 8398a7/action-slack@v3 + with: + status: custom + custom_payload: | + { + channel: '#deployer', + attachments: [{ + color: "#DB4545", + pretext: ``, + text: `💥 NewDot production deploy failed. 💥`, + }] + } + env: + GITHUB_TOKEN: ${{ github.token }} + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }} 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 5b21487d92cd..c53ad2cd3cc7 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -107,8 +107,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1009000400 - versionName "9.0.4-0" + versionCode 1009000512 + versionName "9.0.5-12" // 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/product-illustrations/folder-with-papers.svg b/assets/images/product-illustrations/folder-with-papers.svg new file mode 100644 index 000000000000..3d00fb147ccd --- /dev/null +++ b/assets/images/product-illustrations/folder-with-papers.svg @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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/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/_layouts/default.html b/docs/_layouts/default.html index 4232c565e715..cc45e83efa59 100644 --- a/docs/_layouts/default.html +++ b/docs/_layouts/default.html @@ -14,7 +14,7 @@ - + diff --git a/docs/_sass/_main.scss b/docs/_sass/_main.scss index 29b02d8aeb00..f3bf1035ed56 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/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/Dispute-A-Transaction.md b/docs/articles/expensify-classic/expensify-card/Dispute-A-Transaction.md index 19972b79d5e0..cb86c340dc81 100644 --- a/docs/articles/expensify-classic/expensify-card/Dispute-A-Transaction.md +++ b/docs/articles/expensify-classic/expensify-card/Dispute-A-Transaction.md @@ -1,50 +1,50 @@ --- title: Expensify Card - Transaction Disputes & Fraud -description: Learn how to dispute an Expensify Card transaction. +description: Understand how to dispute an Expensify Card transaction. --- -# Overview -When using your Expensify Visa® Commercial Card, you may come across transaction errors, which can include things like: -- Unrecognized, unauthorized, or fraudulent charges. -- Transactions of an incorrect amount. +# Disputing Expensify Card Transactions +While using your Expensify Visa® Commercial Card, you might encounter transaction errors, such as: +- Unauthorized transaction activity +- Incorrect transaction amounts. - Duplicate charges for a single transaction. -- Missing a promised merchant refund. +- Missing merchant refunds. -You’ll find all the relevant information on handling these below. +When that happens, you may need to file a dispute for one or more transactions. -# How to Navigate the Dispute Process ## Disputing a Transaction - -If you spot an Expensify Card transaction error, please contact us immediately at [concierge@expensify.com](mailto:concierge@expensify.com). After that, we'll ask a few questions to better understand the situation. If the transaction has already settled in your account (no longer pending), we can file a dispute with our card processor on your behalf. - -If you suspect fraud on your Expensify Card, don't hesitate to cancel it by heading to Settings > Account > Credit Card Import > Request A New Card. Better safe than sorry! - -Lastly, if you haven’t enabled Two-Factor Authentication (2FA) yet, please do so ASAP to add an additional layer of security to your account. +If you notice a transaction error on your Expensify Card, contact us immediately at concierge@expensify.com. We will ask a few questions to understand the situation better, and file a dispute with our card processor on your behalf. ## Types of Disputes +The most common types of disputes are: +- Unauthorized or fraudulent disputes +- Service disputes -There are two main dispute types: +### Unauthorized or fraudulent disputes +- Charges made after your card was lost or stolen. +- Unauthorized charges while your card is in your possession (indicating compromised information). +- Continued charges for a canceled recurring subscription. -1. Unauthorized charges/fraud disputes, which include: - - Charges made with your card after it was lost or stolen. - - Unauthorized charges while your card is still in your possession (indicating compromised card information). - - Continued charges for a canceled recurring subscription. +**If there are transactions made with your Expensify Card you don't recognize, you'll want to do the following right away:** +1. Cancel your card by going to _**Settings > Account > Credit Card Import > Request A New Card**_. +2. Enable Two-Factor Authentication (2FA) for added security under _**Settings > Account > Account Details > Two Factor Authentication**_. -2. Service disputes, which include: - - Received damaged or defective merchandise. - - Charged for merchandise but never received it. - - Double-charged for a purchase made with another method (e.g., cash). - - Made a return but didn't receive a timely refund. - - Multiple charges for a single transaction. - - Charges settled for an incorrect amount. +### Service Disputes +- Received damaged or defective merchandise. +- Charged for merchandise that was never received. +- Double-charged for a purchase made with another method (e.g., cash). +- Made a return but didn't receive a refund. +- Multiple charges for a single transaction. +- Charges settled for an incorrect amount. -You don't need to categorize your dispute; we'll handle that. However, this may help you assess if a situation warrants a dispute. In most cases, the initial step for resolving a dispute should be contacting the merchant, as they can often address the issue promptly. +For service disputes, contacting the merchant is often the quickest way to resolve the dispute. ## Simplifying the Dispute Process - -To ensure the dispute process goes smoothly, please: -- Provide detailed information about the disputed charge, including why you're disputing it, what occurred, and any steps you've taken to address the issue. -- If you recognize the merchant but not the charge, and you've transacted with them before, contact the merchant directly, as it may be a non-fraudulent error. -- Include supporting documentation like receipts or cancellation confirmations when submitting your dispute to enhance the likelihood of a favorable resolution (not required but highly recommended). +To ensure a smooth dispute process, please: +- Provide detailed information about the disputed charge, including why you're disputing it and any steps you've taken to address the issue. +- If you recognize the merchant but not the charge, contact the merchant directly. +- Include supporting documentation (e.g., receipts, cancellation confirmations) when submitting your dispute to increase the chances of a favorable resolution (recommended but not required). +- Make sure the transaction isn't pending (pending transactions cannot be disputed). + {% include faq-begin.md %} diff --git a/docs/articles/expensify-classic/expensify-card/Expensify-Card-Reconciliation.md b/docs/articles/expensify-classic/expensify-card/Expensify-Card-Reconciliation.md new file mode 100644 index 000000000000..81eae56fa774 --- /dev/null +++ b/docs/articles/expensify-classic/expensify-card/Expensify-Card-Reconciliation.md @@ -0,0 +1,108 @@ +--- +title: Expensify Card reconciliation +description: Reconcile expenses from Expensify Cards +--- + +
          + +To handle unapproved Expensify Card expenses that are left after you close your books for the month, you can set up auto-reconciliation with an accounting integration, or you can manually reconcile the expenses. + +# Set up automatic reconciliation + +Auto-reconciliation automatically deducts Expensify Card purchases from your company’s settlement account on a daily or monthly basis. + +{% include info.html %} +You must link a business bank account as your settlement account before you can complete this process. +{% include end-info.html %} + +1. Hover over Settings, then click **Domains**. +2. Click the desired domain name. +3. On the Company Cards tab, click the dropdown under the Imported Cards section to select the desired Expensify Card. +4. To the right of the dropdown, click the **Settings** tab. +5. Click the Expensify Card settlement account dropdown and select your settlement business bank account. + - To verify which account is your settlement account: Hover over Settings, then click **Account**. Click the **Payments** tab on the left and verify the bank account listed as the Settlement Account. If these accounts do not match, repeat the steps above to select the correct bank account. +6. Click **Save**. + +If your workspace is connected to a QuickBooks Online, Xero, NetSuite, or Sage Intacct integration, complete the following additional steps. + +1. Click the Expensify Card Reconciliation Account dropdown and select the GL account from your integration for your Settlement Account. Then click **Save**. +2. (Optional) If using the Sage Intacct integration, select your cash-only and accrual-only journals. If your organization operates on a cash-only or accrual-only basis, choose **No Selection** for the journals that do not apply. +3. Click the **Advanced** tab and ensure Auto-Sync is enabled. Then click **Save** +4. Hover over **Settings**, then click **Workspaces**. +5. Open the workspace linked to the integration. +6. Click the **Connections** tab. +7. Next to the desired integration, click **Configure**. +8. Under the Export tab, ensure that the Preferred Exporter is also a Workspace Admin and has an email address associated with your Expensify Cards' domain. For example, if your domain is company.com, the Preferred Exporter's email should be name@company.com. + +# Manually reconcile expenses + +To manually reconcile Expensify Card expenses, + +1. Hover over Settings, then click **Domains**. +2. Click the desired domain name. +3. On the Company Cards tab, click the dropdown under the Imported Cards section to select the desired Expensify Card. +4. To the right of the dropdown, click the **Reconciliation** tab. +5. For the Reconcile toggle, ensure Expenses is selected. +6. Select the start and end dates, then click **Run**. +7. Use the Imported, Approved, and Unapproved totals to manually reconcile your clearing account in your accounting system. + - The Unapproved total should match the final clearing account balance. Depending on your accounting policies, you can use this balance to book an accrual entry by debiting the appropriate expense and crediting the offsetting clearing account in your accounting system. + +## Troubleshooting + +Use the steps below to do additional research if: +- The amounts vary to a degree that needs further investigation. +- The Reconciliation tab was not run when the accounts payable (AP) was closed. +- Multiple subsidiaries within the accounting system closed on different dates. +- There are foreign currency implications in the accounting system. + +To do a more in-depth reconciliation, + +1. In your accounting system, lock your AP. + +{% include info.html %} +It’s best to do this step at the beginning or end of the day. Otherwise, expenses with the same export date may be posted in different accounting periods. +{% include end-info.html %} + +2. In Expensify, click the **Reports** tab. +3. Set the From date filter to the first day of the month or the date of the first applicable Expensify Card expense, and set the To date filter to today’s date. +4. Set the other filters to show **All**. +5. Select all of the expense reports by clicking the checkbox to the top left of the list. If you have more than 50 expense reports, click **Select All**. +6. In the top right corner of the page, click **Export To** and select **All Data - Expense Level Export**. This will generate and send a CSV report to your email. +7. Click the link from the email to automatically download a copy of the report to your computer. +8. Open the report and apply the following filters (or create a pivot with these filters) depending on whether you want to view the daily or monthly settlements: + - Daily settlements: + - Date = the month you are reconciling + - Bank = Expensify Card + - Posted Date = the month you are reconciling + - [Accounting system] Export Non Reimb = blank/after your AP lock date + - Monthly settlements: + - Date = the month you are reconciling + - Bank = Expensify Card + - Posted Date = The first date after your last settlement until the end of the month + - [Accounting system] Export Non Reimb = the current month and new month until your AP lock date + - To determine your total Expensify Card liability at the end of the month, set this filter to blank/after your AP lock date. + +This filtered list should now only include Expensify Card expenses that have a settlement/card payment entry in your accounting system but don’t have a corresponding expense entry (because they have not yet been approved in Expensify). The sum is shown at the bottom of the sheet. + +The sum of the expenses should equal the balance in your Expensify Clearing or Liability Account in your accounting system. + +# Tips + +- Enable [Scheduled Submit](https://help.expensify.com/articles/expensify-classic/workspaces/reports/Scheduled-Submit) to ensure that expenses are submitted regularly and on time. +- Expenses that remain unapproved for several months can complicate the reconciliation process. If you're an admin in Expensify, you can communicate with all employees who have an active Expensify account by going to [new.expensify.com](http://new.expensify.com) and using the #announce room to send a message. This way, you can remind employees to ensure their expenses are submitted and approved before the end of each month. +- Keep in mind that although Expensify Card settlements/card payments will post to your general ledger on the date it is recorded in Expensify, the payment may not be withdrawn from your bank account until the following business day. +- Based on your internal policies, you may want to accrue for the Expensify Cards. + +{% include faq-begin.md %} + +**Why is the amount in my Expensify report so different from the amount in my accounting system?** + +If the Expensify report shows an amount that is significantly different to your accounting system, there are a few ways to identify the issues: +- Double check that the expenses posted to the GL are within the correct month. Filter out these expenses to see if they now match those in the CSV report. +- Use the process outlined above to export a report of all the transactions from your Clearing (for Daily Settlement) or Liability (for monthly settlement) account, then create a pivot table to group the transactions into expenses and settlements. + - Run the settlements report in the “settlements” view of the Reconciliation Dashboard to confirm that the numbers match. + - Compare “Approved” activity to your posted activity within your accounting system to confirm the numbers match. + +{% include faq-end.md %} + +
          diff --git a/docs/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 c1aae6e1265d..2ccce98e6a21 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -19,7 +19,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 9.0.4 + 9.0.5 CFBundleSignature ???? CFBundleURLTypes @@ -40,7 +40,7 @@ CFBundleVersion - 9.0.4.0 + 9.0.5.12 FullStory OrgId diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index 579c99455525..8248e7db0454 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -15,10 +15,10 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 9.0.4 + 9.0.5 CFBundleSignature ???? CFBundleVersion - 9.0.4.0 + 9.0.5.12 diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index 7981169f076b..87cdb420af38 100644 --- a/ios/NotificationServiceExtension/Info.plist +++ b/ios/NotificationServiceExtension/Info.plist @@ -11,9 +11,9 @@ CFBundleName $(PRODUCT_NAME) CFBundleShortVersionString - 9.0.4 + 9.0.5 CFBundleVersion - 9.0.4.0 + 9.0.5.12 NSExtension NSExtensionPointIdentifier diff --git a/ios/Podfile.lock b/ios/Podfile.lock index a5ffdcb4b63c..29ab90c4b7db 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -282,7 +282,7 @@ PODS: - nanopb/encode (= 2.30908.0) - nanopb/decode (2.30908.0) - nanopb/encode (2.30908.0) - - Onfido (29.7.1) + - Onfido (29.7.2) - onfido-react-native-sdk (10.6.0): - glog - hermes-engine @@ -1871,7 +1871,7 @@ PODS: - RNGoogleSignin (10.0.1): - GoogleSignIn (~> 7.0) - React-Core - - RNLiveMarkdown (0.1.88): + - RNLiveMarkdown (0.1.91): - glog - hermes-engine - RCT-Folly (= 2022.05.16.00) @@ -1889,9 +1889,9 @@ PODS: - React-utils - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - - RNLiveMarkdown/common (= 0.1.88) + - RNLiveMarkdown/common (= 0.1.91) - Yoga - - RNLiveMarkdown/common (0.1.88): + - RNLiveMarkdown/common (0.1.91): - glog - hermes-engine - RCT-Folly (= 2022.05.16.00) @@ -2531,7 +2531,7 @@ SPEC CHECKSUMS: MapboxMaps: 87ef0003e6db46e45e7a16939f29ae87e38e7ce2 MapboxMobileEvents: de50b3a4de180dd129c326e09cd12c8adaaa46d6 nanopb: a0ba3315591a9ae0a16a309ee504766e90db0c96 - Onfido: 342cbecd7a4383e98dfe7f9c35e98aaece599062 + Onfido: f3af62ea1c9a419589c133e3e511e5d2c4f3f8af onfido-react-native-sdk: 3e3b0dd70afa97410fb318d54c6a415137968ef2 Plaid: 7829e84db6d766a751c91a402702946d2977ddcb PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 @@ -2614,7 +2614,7 @@ SPEC CHECKSUMS: RNFS: 4ac0f0ea233904cb798630b3c077808c06931688 RNGestureHandler: 74b7b3d06d667ba0bbf41da7718f2607ae0dfe8f RNGoogleSignin: ccaa4a81582cf713eea562c5dd9dc1961a715fd0 - RNLiveMarkdown: e33d2c97863d5480f8f4b45f8b25f801cc43c7f5 + RNLiveMarkdown: 24fbb7370eefee2f325fb64cfe904b111ffcd81b RNLocalize: d4b8af4e442d4bcca54e68fc687a2129b4d71a81 rnmapbox-maps: df8fe93dbd251f25022f4023d31bc04160d4d65c RNPermissions: 0b61d30d21acbeafe25baaa47d9bae40a0c65216 @@ -2631,7 +2631,7 @@ SPEC CHECKSUMS: SocketRocket: f32cd54efbe0f095c4d7594881e52619cfe80b17 Turf: 13d1a92d969ca0311bbc26e8356cca178ce95da2 VisionCamera: 1394a316c7add37e619c48d7aa40b38b954bf055 - Yoga: 64cd2a583ead952b0315d5135bf39e053ae9be70 + Yoga: 1b901a6d6eeba4e8a2e8f308f708691cdb5db312 PODFILE CHECKSUM: d5e281e5370cb0211a104efd90eb5fa7af936e14 diff --git a/jest/setup.ts b/jest/setup.ts index f11a8a4ed631..c1a737c5def8 100644 --- a/jest/setup.ts +++ b/jest/setup.ts @@ -1,6 +1,8 @@ import '@shopify/flash-list/jestSetup'; import 'react-native-gesture-handler/jestSetup'; +import type * as RNKeyboardController from 'react-native-keyboard-controller'; import mockStorage from 'react-native-onyx/dist/storage/__mocks__'; +import type Animated from 'react-native-reanimated'; import 'setimmediate'; import mockFSLibrary from './setupMockFullstoryLib'; import setupMockImages from './setupMockImages'; @@ -20,6 +22,16 @@ jest.mock('react-native-onyx/dist/storage', () => mockStorage); // Mock NativeEventEmitter as it is needed to provide mocks of libraries which include it jest.mock('react-native/Libraries/EventEmitter/NativeEventEmitter'); +// Needed for: https://stackoverflow.com/questions/76903168/mocking-libraries-in-jest +jest.mock('react-native/Libraries/LogBox/LogBox', () => ({ + // eslint-disable-next-line @typescript-eslint/naming-convention + __esModule: true, + default: { + ignoreLogs: jest.fn(), + ignoreAllLogs: jest.fn(), + }, +})); + // Turn off the console logs for timing events. They are not relevant for unit tests and create a lot of noise jest.spyOn(console, 'debug').mockImplementation((...params: string[]) => { if (params[0].startsWith('Timing:')) { @@ -54,5 +66,10 @@ jest.mock('react-native-share', () => ({ default: jest.fn(), })); -// eslint-disable-next-line @typescript-eslint/no-unsafe-return -jest.mock('react-native-keyboard-controller', () => require('react-native-keyboard-controller/jest')); +jest.mock('react-native-reanimated', () => ({ + ...jest.requireActual('react-native-reanimated/mock'), + createAnimatedPropAdapter: jest.fn, + useReducedMotion: jest.fn, +})); + +jest.mock('react-native-keyboard-controller', () => require('react-native-keyboard-controller/jest')); diff --git a/jest/setupMockFullstoryLib.ts b/jest/setupMockFullstoryLib.ts index 9edfccab9441..eae3ea1f51bd 100644 --- a/jest/setupMockFullstoryLib.ts +++ b/jest/setupMockFullstoryLib.ts @@ -15,7 +15,7 @@ export default function mockFSLibrary() { return { FSPage(): FSPageInterface { return { - start: jest.fn(), + start: jest.fn(() => {}), }; }, default: Fullstory, diff --git a/package-lock.json b/package-lock.json index c92ca2ec3813..c401dfe77198 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,19 +1,19 @@ { "name": "new.expensify", - "version": "9.0.4-0", + "version": "9.0.5-12", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "9.0.4-0", + "version": "9.0.5-12", "hasInstallScript": true, "license": "MIT", "dependencies": { "@babel/plugin-proposal-private-methods": "^7.18.6", "@babel/plugin-proposal-private-property-in-object": "^7.21.11", "@dotlottie/react-player": "^1.6.3", - "@expensify/react-native-live-markdown": "0.1.88", + "@expensify/react-native-live-markdown": "0.1.91", "@expo/metro-runtime": "~3.1.1", "@formatjs/intl-datetimeformat": "^6.10.0", "@formatjs/intl-listformat": "^7.2.2", @@ -55,7 +55,7 @@ "date-fns-tz": "^2.0.0", "dom-serializer": "^0.2.2", "domhandler": "^4.3.0", - "expensify-common": "2.0.19", + "expensify-common": "2.0.26", "expo": "^50.0.3", "expo-av": "~13.10.4", "expo-image": "1.11.0", @@ -79,7 +79,7 @@ "react-content-loader": "^7.0.0", "react-dom": "18.1.0", "react-error-boundary": "^4.0.11", - "react-fast-pdf": "1.0.13", + "react-fast-pdf": "1.0.14", "react-map-gl": "^7.1.3", "react-native": "0.73.4", "react-native-android-location-enabler": "^2.0.1", @@ -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", @@ -3783,9 +3784,9 @@ } }, "node_modules/@expensify/react-native-live-markdown": { - "version": "0.1.88", - "resolved": "https://registry.npmjs.org/@expensify/react-native-live-markdown/-/react-native-live-markdown-0.1.88.tgz", - "integrity": "sha512-78X5ACV+OL+aL6pfJAXyHkNuMGUc4Rheo4qLkIwLpmUIAiAxmY0B2lch5XHSNGf1a5ofvVbdQ6kl84+4E6DwlQ==", + "version": "0.1.91", + "resolved": "https://registry.npmjs.org/@expensify/react-native-live-markdown/-/react-native-live-markdown-0.1.91.tgz", + "integrity": "sha512-6uQTgwhpvLqQKdtNqSgh45sRuQRXzv/WwyhdvQNge6EYtulyGFqT82GIP+LIGW8Xnl73nzFZTuMKwWxFFR/Cow==", "workspaces": [ "parser", "example", @@ -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", @@ -25973,9 +25974,9 @@ } }, "node_modules/expensify-common": { - "version": "2.0.19", - "resolved": "https://registry.npmjs.org/expensify-common/-/expensify-common-2.0.19.tgz", - "integrity": "sha512-GdWlYiHOAapy/jxjcvL9NKGOofhoEuKIwvJNGNVHbDXcA+0NxVCNYrHt1yrLnVcE4KtK6PGT6fQ2Lp8NTCoA+g==", + "version": "2.0.26", + "resolved": "https://registry.npmjs.org/expensify-common/-/expensify-common-2.0.26.tgz", + "integrity": "sha512-3GORs2xfx78SoKLDh4lXpk4Bx61sAVNnlo23VB803zs7qZz8/Oq3neKedtEJuRAmUps0C1Y5y9xZE8nrPO31nQ==", "dependencies": { "awesome-phonenumber": "^5.4.0", "classnames": "2.5.0", @@ -25987,7 +25988,7 @@ "prop-types": "15.8.1", "react": "16.12.0", "react-dom": "16.12.0", - "semver": "^7.6.0", + "semver": "^7.6.2", "simply-deferred": "git+https://github.com/Expensify/simply-deferred.git#77a08a95754660c7bd6e0b6979fdf84e8e831bf5", "ua-parser-js": "^1.0.37" } @@ -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 @@ -36894,9 +36987,9 @@ } }, "node_modules/react-fast-pdf": { - "version": "1.0.13", - "resolved": "https://registry.npmjs.org/react-fast-pdf/-/react-fast-pdf-1.0.13.tgz", - "integrity": "sha512-rF7NQZ26rJAI8ysRJaG71dl2c7AIq48ibbn7xCyF3lEZ/yOjA8BeR0utRwDjaHGtswQscgETboilhaaH5UtIYg==", + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/react-fast-pdf/-/react-fast-pdf-1.0.14.tgz", + "integrity": "sha512-iWomykxvnZtokIKpRK5xpaRfXz9ufrY7AVANtIBYsAZtX5/7VDlpIQwieljfMZwFc96TyceCnneufsgXpykTQw==", "dependencies": { "react-pdf": "^7.7.0", "react-window": "^1.8.10" @@ -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", @@ -39380,11 +39473,9 @@ } }, "node_modules/semver": { - "version": "7.6.0", - "license": "ISC", - "dependencies": { - "lru-cache": "^6.0.0" - }, + "version": "7.6.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz", + "integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==", "bin": { "semver": "bin/semver.js" }, @@ -41840,9 +41931,10 @@ } }, "node_modules/type-fest": { - "version": "4.10.3", + "version": "4.20.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.20.0.tgz", + "integrity": "sha512-MBh+PHUHHisjXf4tlx0CFWoMdjx8zCMLJHOjnV1prABYZFHqtFOyauCIK2/7w4oIfwkF8iNhLtnJEfVY2vn3iw==", "dev": true, - "license": "(MIT OR CC0-1.0)", "engines": { "node": ">=16" }, diff --git a/package.json b/package.json index e4bd1d99db16..5420a3e886ef 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "9.0.4-0", + "version": "9.0.5-12", "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,13 +60,14 @@ "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", "@babel/plugin-proposal-private-property-in-object": "^7.21.11", "@dotlottie/react-player": "^1.6.3", - "@expensify/react-native-live-markdown": "0.1.88", + "@expensify/react-native-live-markdown": "0.1.91", "@expo/metro-runtime": "~3.1.1", "@formatjs/intl-datetimeformat": "^6.10.0", "@formatjs/intl-listformat": "^7.2.2", @@ -108,7 +109,7 @@ "date-fns-tz": "^2.0.0", "dom-serializer": "^0.2.2", "domhandler": "^4.3.0", - "expensify-common": "2.0.19", + "expensify-common": "2.0.26", "expo": "^50.0.3", "expo-av": "~13.10.4", "expo-image": "1.11.0", @@ -132,7 +133,7 @@ "react-content-loader": "^7.0.0", "react-dom": "18.1.0", "react-error-boundary": "^4.0.11", - "react-fast-pdf": "1.0.13", + "react-fast-pdf": "1.0.14", "react-map-gl": "^7.1.3", "react-native": "0.73.4", "react-native-android-location-enabler": "^2.0.1", @@ -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.85.patch deleted file mode 100644 index f745786a088e..000000000000 --- a/patches/@expensify+react-native-live-markdown+0.1.85.patch +++ /dev/null @@ -1,13 +0,0 @@ -diff --git a/node_modules/@expensify/react-native-live-markdown/lib/module/web/cursorUtils.js b/node_modules/@expensify/react-native-live-markdown/lib/module/web/cursorUtils.js -index e975fb2..6a4b510 100644 ---- a/node_modules/@expensify/react-native-live-markdown/lib/module/web/cursorUtils.js -+++ b/node_modules/@expensify/react-native-live-markdown/lib/module/web/cursorUtils.js -@@ -53,7 +53,7 @@ function setCursorPosition(target, start, end = null) { - // 3. Caret at the end of whole input, when pressing enter - // 4. All other placements - if (prevChar === '\n' && prevTextLength !== undefined && prevTextLength < textCharacters.length) { -- if (nextChar !== '\n') { -+ if (nextChar !== '\n' && i !== n - 1 && nextChar) { - range.setStart(textNodes[i + 1], 0); - } else if (i !== textNodes.length - 1) { - range.setStart(textNodes[i], 1); diff --git a/patches/@expensify+react-native-live-markdown+0.1.91.patch b/patches/@expensify+react-native-live-markdown+0.1.91.patch new file mode 100644 index 000000000000..c77e46accae3 --- /dev/null +++ b/patches/@expensify+react-native-live-markdown+0.1.91.patch @@ -0,0 +1,13 @@ +diff --git a/node_modules/@expensify/react-native-live-markdown/src/web/cursorUtils.ts b/node_modules/@expensify/react-native-live-markdown/src/web/cursorUtils.ts +index 1cda659..ba5c3c3 100644 +--- a/node_modules/@expensify/react-native-live-markdown/src/web/cursorUtils.ts ++++ b/node_modules/@expensify/react-native-live-markdown/src/web/cursorUtils.ts +@@ -66,7 +66,7 @@ function setCursorPosition(target: HTMLElement, start: number, end: number | nul + // 3. Caret at the end of whole input, when pressing enter + // 4. All other placements + if (prevChar === '\n' && prevTextLength !== undefined && prevTextLength < textCharacters.length) { +- if (nextChar !== '\n') { ++ if (nextChar && nextChar !== '\n' && i !== n - 1) { + range.setStart(textNodes[i + 1] as Node, 0); + } else if (i !== textNodes.length - 1) { + range.setStart(textNodes[i] as Node, 1); 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/App.tsx b/src/App.tsx index 21025d34a661..98b5d4afeb1d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -20,6 +20,7 @@ import OnyxProvider from './components/OnyxProvider'; import PopoverContextProvider from './components/PopoverProvider'; import SafeArea from './components/SafeArea'; import ScrollOffsetContextProvider from './components/ScrollOffsetContextProvider'; +import {SearchContextProvider} from './components/Search/SearchContext'; import ThemeIllustrationsProvider from './components/ThemeIllustrationsProvider'; import ThemeProvider from './components/ThemeProvider'; import ThemeStylesProvider from './components/ThemeStylesProvider'; @@ -91,6 +92,7 @@ function App({url}: AppProps) { VolumeContextProvider, VideoPopoverMenuContextProvider, KeyboardProvider, + SearchContextProvider, ]} > diff --git a/src/CONST.ts b/src/CONST.ts index 00f2245a55c0..b809bdaacaf6 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -74,6 +74,7 @@ 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', @@ -369,7 +370,6 @@ const CONST = { WORKSPACE_FEEDS: 'workspaceFeeds', NETSUITE_USA_TAX: 'netsuiteUsaTax', INTACCT_ON_NEW_EXPENSIFY: 'intacctOnNewExpensify', - COMMENT_LINKING: 'commentLinking', }, BUTTON_STATES: { DEFAULT: 'default', @@ -609,6 +609,7 @@ const CONST = { TRAVEL_TERMS_URL: `${USE_EXPENSIFY_URL}/travelterms`, EXPENSIFY_PACKAGE_FOR_SAGE_INTACCT: 'https://www.expensify.com/tools/integrations/downloadPackage', EXPENSIFY_PACKAGE_FOR_SAGE_INTACCT_FILE_NAME: 'ExpensifyPackageForSageIntacct', + SAGE_INTACCT_INSTRUCTIONS: 'https://help.expensify.com/articles/expensify-classic/integrations/accounting-integrations/Sage-Intacct', HOW_TO_CONNECT_TO_SAGE_INTACCT: 'https://help.expensify.com/articles/expensify-classic/integrations/accounting-integrations/Sage-Intacct#how-to-connect-to-sage-intacct', PRICING: `https://www.expensify.com/pricing`, @@ -720,7 +721,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', @@ -791,6 +792,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', @@ -1344,18 +1346,75 @@ const CONST = { }, }, + SAGE_INTACCT_MAPPING_VALUE: { + NONE: 'NONE', + DEFAULT: 'DEFAULT', + TAG: 'TAG', + REPORT_FIELD: 'REPORT_FIELD', + }, + + SAGE_INTACCT_CONFIG: { + MAPPINGS: { + DEPARTMENTS: 'departments', + CLASSES: 'classes', + LOCATIONS: 'locations', + CUSTOMERS: 'customers', + PROJECTS: 'projects', + }, + SYNC_ITEMS: 'syncItems', + TAX: 'tax', + 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', @@ -1373,10 +1432,78 @@ const CONST = { PROVINCIAL_TAX_POSTING_ACCOUNT: 'provincialTaxPostingAccount', ALLOW_FOREIGN_CURRENCY: 'allowForeignCurrency', EXPORT_TO_NEXT_OPEN_PERIOD: 'exportToNextOpenPeriod', - IMPORT_FIELDS: ['departments', 'classes', 'locations', 'customers', 'jobs'], - IMPORT_CUSTOM_FIELDS: ['customSegments', 'customLists'], + 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: { + CUSTOM_SEGMENTS: 'customSegments', + CUSTOM_LISTS: 'customLists', + }, + CUSTOM_SEGMENT_FIELDS: ['segmentName', 'internalID', 'scriptID', 'mapping'], + CUSTOM_LIST_FIELDS: ['listName', 'internalID', 'transactionFieldID', 'mapping'], + 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_CUSTOM_LIST_LIMIT: 8, + NETSUITE_ADD_CUSTOM_LIST_STEP_NAMES: ['1', '2,', '3', '4'], + NETSUITE_ADD_CUSTOM_SEGMENT_STEP_NAMES: ['1', '2,', '3', '4', '5', '6,'], + }, + + NETSUITE_CUSTOM_FIELD_SUBSTEP_INDEXES: { + CUSTOM_LISTS: { + CUSTOM_LIST_PICKER: 0, + TRANSACTION_FIELD_ID: 1, + MAPPING: 2, + CONFIRM: 3, + }, + CUSTOM_SEGMENTS: { + SEGMENT_TYPE: 0, + SEGMENT_NAME: 1, + INTERNAL_ID: 2, + SCRIPT_ID: 3, + MAPPING: 4, + CONFIRM: 5, + }, + }, + + NETSUITE_CUSTOM_RECORD_TYPES: { + CUSTOM_SEGMENT: 'customSegment', + CUSTOM_RECORD: 'customRecord', + }, + + NETSUITE_FORM_STEPS_HEADER_HEIGHT: 40, + + NETSUITE_IMPORT: { + HELP_LINKS: { + CUSTOM_SEGMENTS: 'https://help.expensify.com/articles/expensify-classic/integrations/accounting-integrations/NetSuite#custom-segments', + CUSTOM_LISTS: 'https://help.expensify.com/articles/expensify-classic/integrations/accounting-integrations/NetSuite#custom-lists', }, }, @@ -1392,6 +1519,12 @@ const CONST = { JOURNAL_ENTRY: 'JOURNAL_ENTRY', }, + NETSUITE_MAP_EXPORT_DESTINATION: { + EXPENSE_REPORT: 'expenseReport', + VENDOR_BILL: 'vendorBill', + JOURNAL_ENTRY: 'journalEntry', + }, + NETSUITE_INVOICE_ITEM_PREFERENCE: { CREATE: 'create', SELECT: 'select', @@ -1407,6 +1540,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) * @@ -1981,6 +2146,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: { @@ -2059,6 +2227,10 @@ const CONST = { CARD_NAME: 'CardName', CONFIRMATION: 'Confirmation', }, + CARD_TYPE: { + PHYSICAL: 'physical', + VIRTUAL: 'virtual', + }, }, AVATAR_ROW_SIZE: { DEFAULT: 4, @@ -2280,6 +2452,8 @@ const CONST = { PRIVATE_NOTES: 'privateNotes', DELETE: 'delete', MARK_AS_INCOMPLETE: 'markAsIncomplete', + CANCEL_PAYMENT: 'cancelPayment', + UNAPPROVE: 'unapprove', }, EDIT_REQUEST_FIELD: { AMOUNT: 'amount', @@ -3724,6 +3898,7 @@ const CONST = { }, EVENTS: { SCROLLING: 'scrolling', + ON_RETURN_TO_OLD_DOT: 'onReturnToOldDot', }, CHAT_HEADER_LOADER_HEIGHT: 36, @@ -3875,6 +4050,7 @@ const CONST = { TAX_REQUIRED: 'taxRequired', HOLD: 'hold', }, + REVIEW_DUPLICATES_ORDER: ['merchant', 'category', 'tag', 'description', 'taxCode', 'billable', 'reimbursable'], /** Context menu types */ CONTEXT_MENU_TYPES: { @@ -4989,6 +5165,9 @@ const CONST = { DONE: 'done', PAID: 'paid', VIEW: 'view', + REVIEW: 'review', + HOLD: 'hold', + UNHOLD: 'unhold', }, TRANSACTION_TYPE: { CASH: 'cash', @@ -5035,10 +5214,12 @@ const CONST = { SUBSCRIPTION_SIZE_LIMIT: 20000, + PAGINATION_START_ID: '-1', + PAGINATION_END_ID: '-2', + PAYMENT_CARD_CURRENCY: { USD: 'USD', AUD: 'AUD', - GBP: 'GBP', NZD: 'NZD', }, @@ -5069,12 +5250,25 @@ const CONST = { }, EXCLUDE_FROM_LAST_VISITED_PATH: [SCREENS.NOT_FOUND, SCREENS.SAML_SIGN_IN, SCREENS.VALIDATE_LOGIN] as string[], - + UPGRADE_FEATURE_INTRO_MAPPING: [ + { + id: 'reportFields', + alias: 'report-fields', + name: 'Report Fields', + title: 'workspace.upgrade.reportFields.title', + description: 'workspace.upgrade.reportFields.description', + icon: 'Pencil', + }, + ], REPORT_FIELD_TYPES: { TEXT: 'text', DATE: 'date', LIST: 'dropdown', }, + + NAVIGATION_ACTIONS: { + RESET: 'RESET', + }, } 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 709347fa71cd..bd4b294a6d68 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -320,6 +320,9 @@ const ONYXKEYS = { /** Onboarding Purpose selected by the user during Onboarding flow */ ONBOARDING_PURPOSE_SELECTED: 'onboardingPurposeSelected', + /** Onboarding error message to be displayed to the user */ + ONBOARDING_ERROR_MESSAGE: 'onboardingErrorMessage', + /** Onboarding policyID selected by the user during Onboarding flow */ ONBOARDING_POLICY_ID: 'onboardingPolicyID', @@ -405,6 +408,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_', @@ -459,8 +463,8 @@ const ONYXKEYS = { WORKSPACE_RATE_AND_UNIT_FORM_DRAFT: 'workspaceRateAndUnitFormDraft', WORKSPACE_TAX_CUSTOM_NAME: 'workspaceTaxCustomName', WORKSPACE_TAX_CUSTOM_NAME_DRAFT: 'workspaceTaxCustomNameDraft', - WORKSPACE_REPORT_FIELDS_FORM: 'workspaceReportFieldsForm', - WORKSPACE_REPORT_FIELDS_FORM_DRAFT: 'workspaceReportFieldsFormDraft', + 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', @@ -555,10 +559,22 @@ 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_CUSTOM_FIELD_FORM: 'netSuiteCustomFieldForm', + NETSUITE_CUSTOM_FIELD_FORM_DRAFT: 'netSuiteCustomFieldFormDraft', + NETSUITE_CUSTOM_SEGMENT_ADD_FORM: 'netSuiteCustomSegmentAddForm', + NETSUITE_CUSTOM_SEGMENT_ADD_FORM_DRAFT: 'netSuiteCustomSegmentAddFormDraft', + NETSUITE_CUSTOM_LIST_ADD_FORM: 'netSuiteCustomListAddForm', + NETSUITE_CUSTOM_LIST_ADD_FORM_DRAFT: 'netSuiteCustomListAddFormDraft', + NETSUITE_TOKEN_INPUT_FORM: 'netsuiteTokenInputForm', + NETSUITE_TOKEN_INPUT_FORM_DRAFT: 'netsuiteTokenInputFormDraft', + NETSUITE_CUSTOM_FORM_ID_FORM: 'netsuiteCustomFormIDForm', + NETSUITE_CUSTOM_FORM_ID_FORM_DRAFT: 'netsuiteCustomFormIDFormDraft', + SAGE_INTACCT_DIMENSION_TYPE_FORM: 'sageIntacctDimensionTypeForm', + SAGE_INTACCT_DIMENSION_TYPE_FORM_DRAFT: 'sageIntacctDimensionTypeFormDraft', }, } as const; @@ -571,7 +587,7 @@ type OnyxFormValuesMapping = { [ONYXKEYS.FORMS.WORKSPACE_TAG_FORM]: FormTypes.WorkspaceTagForm; [ONYXKEYS.FORMS.WORKSPACE_RATE_AND_UNIT_FORM]: FormTypes.WorkspaceRateAndUnitForm; [ONYXKEYS.FORMS.WORKSPACE_TAX_CUSTOM_NAME]: FormTypes.WorkspaceTaxCustomName; - [ONYXKEYS.FORMS.WORKSPACE_REPORT_FIELDS_FORM]: FormTypes.WorkspaceReportFieldsForm; + [ONYXKEYS.FORMS.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; @@ -622,6 +638,12 @@ 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_CUSTOM_FIELD_FORM]: FormTypes.NetSuiteCustomFieldForm; + [ONYXKEYS.FORMS.NETSUITE_CUSTOM_LIST_ADD_FORM]: FormTypes.NetSuiteCustomFieldForm; + [ONYXKEYS.FORMS.NETSUITE_CUSTOM_SEGMENT_ADD_FORM]: FormTypes.NetSuiteCustomFieldForm; + [ONYXKEYS.FORMS.NETSUITE_TOKEN_INPUT_FORM]: FormTypes.NetSuiteTokenInputForm; + [ONYXKEYS.FORMS.NETSUITE_CUSTOM_FORM_ID_FORM]: FormTypes.NetSuiteCustomFormIDForm; + [ONYXKEYS.FORMS.SAGE_INTACCT_DIMENSION_TYPE_FORM]: FormTypes.SageIntacctDimensionForm; }; type OnyxFormDraftValuesMapping = { @@ -646,6 +668,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; @@ -774,6 +797,7 @@ type OnyxValuesMapping = { [ONYXKEYS.MAX_CANVAS_HEIGHT]: number; [ONYXKEYS.MAX_CANVAS_WIDTH]: number; [ONYXKEYS.ONBOARDING_PURPOSE_SELECTED]: string; + [ONYXKEYS.ONBOARDING_ERROR_MESSAGE]: string; [ONYXKEYS.ONBOARDING_POLICY_ID]: string; [ONYXKEYS.ONBOARDING_ADMINS_CHAT_REPORT_ID]: string; [ONYXKEYS.IS_SEARCHING_FOR_REPORTS]: boolean; @@ -808,6 +832,7 @@ type OnyxFormDraftKey = keyof OnyxFormDraftValuesMapping; type OnyxValueKey = keyof OnyxValuesMapping; type OnyxKey = OnyxValueKey | OnyxCollectionKey | OnyxFormKey | OnyxFormDraftKey; +type OnyxPagesKey = typeof ONYXKEYS.COLLECTION.REPORT_ACTIONS_PAGES; type MissingOnyxKeysError = `Error: Types don't match, OnyxKey type is missing: ${Exclude}`; /** If this type errors, it means that the `OnyxKey` type is missing some keys. */ @@ -815,4 +840,4 @@ type MissingOnyxKeysError = `Error: Types don't match, OnyxKey type is missing: type AssertOnyxKeys = AssertTypesEqual; export default ONYXKEYS; -export type {OnyxCollectionKey, OnyxCollectionValuesMapping, OnyxFormDraftKey, OnyxFormKey, OnyxFormValuesMapping, OnyxKey, OnyxValueKey, OnyxValues}; +export type {OnyxCollectionKey, OnyxCollectionValuesMapping, OnyxFormDraftKey, OnyxFormKey, OnyxFormValuesMapping, OnyxKey, OnyxPagesKey, OnyxValueKey, OnyxValues}; diff --git a/src/ROUTES.ts b/src/ROUTES.ts index a70d6e7502ae..a54bb4f5cca5 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -1,8 +1,9 @@ -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'; import type {AuthScreensParamList} from './libs/Navigation/types'; +import type {SageIntacctMappingName} from './types/onyx/Policy'; import type {SearchQuery} from './types/onyx/SearchResults'; import type AssertTypesNotEqual from './types/utils/AssertTypesNotEqual'; @@ -53,6 +54,11 @@ const ROUTES = { getRoute: (query: string, reportID: string) => `search/${query}/view/${reportID}` as const, }, + TRANSACTION_HOLD_REASON_RHP: { + route: '/search/:query/hold/:transactionID', + getRoute: (query: string, transactionID: string) => `search/${query}/hold/${transactionID}` as const, + }, + // This is a utility route used to go to the user's concierge chat, or the sign-in page if the user's not authenticated CONCIERGE: 'concierge', FLAG_COMMENT: { @@ -678,6 +684,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, @@ -787,33 +797,39 @@ const ROUTES = { 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/reportFields/new/listValues', - getRoute: (policyID: string) => `settings/workspaces/${policyID}/reportFields/new/listValues` as const, + 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/reportFields/new/addValue', - getRoute: (policyID: string) => `settings/workspaces/${policyID}/reportFields/new/addValue` as const, + 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/reportFields/new/:valueIndex', - getRoute: (policyID: string, valueIndex: number) => `settings/workspaces/${policyID}/reportFields/new/${valueIndex}` as const, + 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/reportFields/new/:valueIndex/edit', - getRoute: (policyID: string, valueIndex: number) => `settings/workspaces/${policyID}/reportFields/new/${valueIndex}/edit` as const, + 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, }, - // 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_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, @@ -866,6 +882,34 @@ const ROUTES = { route: 'r/:threadReportID/duplicates/review', getRoute: (threadReportID: string) => `r/${threadReportID}/duplicates/review` as const, }, + TRANSACTION_DUPLICATE_REVIEW_MERCHANT_PAGE: { + route: 'r/:threadReportID/duplicates/review/merchant', + getRoute: (threadReportID: string) => `r/${threadReportID}/duplicates/review/merchant` as const, + }, + TRANSACTION_DUPLICATE_REVIEW_CATEGORY_PAGE: { + route: 'r/:threadReportID/duplicates/review/category', + getRoute: (threadReportID: string) => `r/${threadReportID}/duplicates/review/category` as const, + }, + TRANSACTION_DUPLICATE_REVIEW_TAG_PAGE: { + route: 'r/:threadReportID/duplicates/review/tag', + getRoute: (threadReportID: string) => `r/${threadReportID}/duplicates/review/tag` as const, + }, + TRANSACTION_DUPLICATE_REVIEW_TAX_CODE_PAGE: { + route: 'r/:threadReportID/duplicates/review/tax-code', + getRoute: (threadReportID: string) => `r/${threadReportID}/duplicates/review/tax-code` as const, + }, + TRANSACTION_DUPLICATE_REVIEW_DESCRIPTION_PAGE: { + route: 'r/:threadReportID/duplicates/confirm', + getRoute: (threadReportID: string) => `r/${threadReportID}/duplicates/confirm` as const, + }, + TRANSACTION_DUPLICATE_REVIEW_REIMBURSABLE_PAGE: { + route: 'r/:threadReportID/duplicates/review/reimbursable', + getRoute: (threadReportID: string) => `r/${threadReportID}/duplicates/review/reimbursable` as const, + }, + TRANSACTION_DUPLICATE_REVIEW_BILLABLE_PAGE: { + route: 'r/:threadReportID/duplicates/review/billable', + getRoute: (threadReportID: string) => `r/${threadReportID}/duplicates/review/billable` as const, + }, POLICY_ACCOUNTING_XERO_IMPORT: { route: 'settings/workspaces/:policyID/accounting/xero/import', @@ -960,10 +1004,50 @@ const ROUTES = { route: 'settings/workspaces/:policyID/accounting/netsuite/subsidiary-selector', getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/netsuite/subsidiary-selector` as const, }, + POLICY_ACCOUNTING_NETSUITE_TOKEN_INPUT: { + route: 'settings/workspaces/:policyID/accounting/netsuite/token-input', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/netsuite/token-input` as const, + }, POLICY_ACCOUNTING_NETSUITE_IMPORT: { route: 'settings/workspaces/:policyID/accounting/netsuite/import', getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/netsuite/import` as const, }, + POLICY_ACCOUNTING_NETSUITE_IMPORT_MAPPING: { + route: 'settings/workspaces/:policyID/accounting/netsuite/import/mapping/:importField', + getRoute: (policyID: string, importField: TupleToUnion) => + `settings/workspaces/${policyID}/accounting/netsuite/import/mapping/${importField}` as const, + }, + POLICY_ACCOUNTING_NETSUITE_IMPORT_CUSTOM_FIELD_MAPPING: { + route: 'settings/workspaces/:policyID/accounting/netsuite/import/custom/:importCustomField', + getRoute: (policyID: string, importCustomField: ValueOf) => + `settings/workspaces/${policyID}/accounting/netsuite/import/custom/${importCustomField}` as const, + }, + POLICY_ACCOUNTING_NETSUITE_IMPORT_CUSTOM_FIELD_VIEW: { + route: 'settings/workspaces/:policyID/accounting/netsuite/import/custom/:importCustomField/view/:valueIndex', + getRoute: (policyID: string, importCustomField: ValueOf, valueIndex: number) => + `settings/workspaces/${policyID}/accounting/netsuite/import/custom/${importCustomField}/view/${valueIndex}` as const, + }, + POLICY_ACCOUNTING_NETSUITE_IMPORT_CUSTOM_FIELD_EDIT: { + route: 'settings/workspaces/:policyID/accounting/netsuite/import/custom/:importCustomField/edit/:valueIndex/:fieldName', + getRoute: (policyID: string, importCustomField: ValueOf, valueIndex: number, fieldName: string) => + `settings/workspaces/${policyID}/accounting/netsuite/import/custom/${importCustomField}/edit/${valueIndex}/${fieldName}` as const, + }, + POLICY_ACCOUNTING_NETSUITE_IMPORT_CUSTOM_LIST_ADD: { + route: 'settings/workspaces/:policyID/accounting/netsuite/import/custom-list/new', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/netsuite/import/custom-list/new` as const, + }, + POLICY_ACCOUNTING_NETSUITE_IMPORT_CUSTOM_SEGMENT_ADD: { + route: 'settings/workspaces/:policyID/accounting/netsuite/import/custom-segment/new', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/netsuite/import/custom-segment/new` as const, + }, + POLICY_ACCOUNTING_NETSUITE_IMPORT_CUSTOMERS_OR_PROJECTS: { + route: 'settings/workspaces/:policyID/accounting/netsuite/import/customer-projects', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/netsuite/import/customer-projects` as const, + }, + POLICY_ACCOUNTING_NETSUITE_IMPORT_CUSTOMERS_OR_PROJECTS_SELECT: { + route: 'settings/workspaces/:policyID/accounting/netsuite/import/customer-projects/select', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/netsuite/import/customer-projects/select` as const, + }, POLICY_ACCOUNTING_NETSUITE_EXPORT: { route: 'settings/workspaces/:policyID/connections/netsuite/export/', getRoute: (policyID: string) => `settings/workspaces/${policyID}/connections/netsuite/export/` as const, @@ -1021,6 +1105,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, @@ -1033,6 +1150,66 @@ 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_IMPORT: { + route: 'settings/workspaces/:policyID/accounting/sage-intacct/import', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/sage-intacct/import` as const, + }, + POLICY_ACCOUNTING_SAGE_INTACCT_TOGGLE_MAPPINGS: { + route: 'settings/workspaces/:policyID/accounting/sage-intacct/import/toggle-mapping/:mapping', + getRoute: (policyID: string, mapping: SageIntacctMappingName) => `settings/workspaces/${policyID}/accounting/sage-intacct/import/toggle-mapping/${mapping}` as const, + }, + POLICY_ACCOUNTING_SAGE_INTACCT_MAPPINGS_TYPE: { + route: 'settings/workspaces/:policyID/accounting/sage-intacct/import/mapping-type/:mapping', + getRoute: (policyID: string, mapping: string) => `settings/workspaces/${policyID}/accounting/sage-intacct/import/mapping-type/${mapping}` as const, + }, + POLICY_ACCOUNTING_SAGE_INTACCT_USER_DIMENSIONS: { + route: 'settings/workspaces/:policyID/accounting/sage-intacct/import/user-dimensions', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/sage-intacct/import/user-dimensions` as const, + }, + POLICY_ACCOUNTING_SAGE_INTACCT_ADD_USER_DIMENSION: { + route: 'settings/workspaces/:policyID/accounting/sage-intacct/import/add-user-dimension', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/sage-intacct/import/add-user-dimension` as const, + }, + POLICY_ACCOUNTING_SAGE_INTACCT_EDIT_USER_DIMENSION: { + route: 'settings/workspaces/:policyID/accounting/sage-intacct/import/edit-user-dimension/:dimensionName', + getRoute: (policyID: string, dimensionName: string) => `settings/workspaces/${policyID}/accounting/sage-intacct/import/edit-user-dimension/${dimensionName}` 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 e12ccfdab072..d2a6b7c19ddd 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -30,6 +30,7 @@ const SCREENS = { SEARCH: { CENTRAL_PANE: 'Search_Central_Pane', REPORT_RHP: 'Search_Report_RHP', + TRANSACTION_HOLD_REASON_RHP: 'Search_Transaction_Hold_Reason_RHP', BOTTOM_TAB: 'Search_Bottom_Tab', }, SETTINGS: { @@ -186,6 +187,13 @@ const SCREENS = { TRANSACTION_DUPLICATE: { REVIEW: 'Transaction_Duplicate_Review', + MERCHANT: 'Transaction_Duplicate_Merchant', + CATEGORY: 'Transaction_Duplicate_Category', + TAG: 'Transaction_Duplicate_Tag', + DESCRIPTION: 'Transaction_Duplicate_Description', + TAX_CODE: 'Transaction_Duplicate_Tax_Code', + REIMBURSABLE: 'Transaction_Duplicate_Reimburable', + BILLABLE: 'Transaction_Duplicate_Billable', }, IOU_SEND: { @@ -272,6 +280,15 @@ const SCREENS = { XERO_EXPORT_PREFERRED_EXPORTER_SELECT: 'Workspace_Accounting_Xero_Export_Preferred_Exporter_Select', XERO_BILL_PAYMENT_ACCOUNT_SELECTOR: 'Policy_Accounting_Xero_Bill_Payment_Account_Selector', XERO_EXPORT_BANK_ACCOUNT_SELECT: 'Policy_Accounting_Xero_Export_Bank_Account_Select', + NETSUITE_IMPORT_MAPPING: 'Policy_Accounting_NetSuite_Import_Mapping', + NETSUITE_IMPORT_CUSTOM_FIELD: 'Policy_Accounting_NetSuite_Import_Custom_Field', + NETSUITE_IMPORT_CUSTOM_FIELD_VIEW: 'Policy_Accounting_NetSuite_Import_Custom_Field_View', + NETSUITE_IMPORT_CUSTOM_FIELD_EDIT: 'Policy_Accounting_NetSuite_Import_Custom_Field_Edit', + NETSUITE_IMPORT_CUSTOM_LIST_ADD: 'Policy_Accounting_NetSuite_Import_Custom_List_Add', + NETSUITE_IMPORT_CUSTOM_SEGMENT_ADD: 'Policy_Accounting_NetSuite_Import_Custom_Segment_Add', + NETSUITE_IMPORT_CUSTOMERS_OR_PROJECTS: 'Policy_Accounting_NetSuite_Import_CustomersOrProjects', + NETSUITE_IMPORT_CUSTOMERS_OR_PROJECTS_SELECT: 'Policy_Accounting_NetSuite_Import_CustomersOrProjects_Select', + NETSUITE_TOKEN_INPUT: 'Policy_Accounting_NetSuite_Token_Input', NETSUITE_SUBSIDIARY_SELECTOR: 'Policy_Accounting_NetSuite_Subsidiary_Selector', NETSUITE_IMPORT: 'Policy_Accounting_NetSuite_Import', NETSUITE_EXPORT: 'Policy_Accounting_NetSuite_Export', @@ -287,9 +304,32 @@ 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_IMPORT: 'Policy_Accounting_Sage_Intacct_Import', + SAGE_INTACCT_TOGGLE_MAPPING: 'Policy_Accounting_Sage_Intacct_Toggle_Mapping', + SAGE_INTACCT_MAPPING_TYPE: 'Policy_Accounting_Sage_Intacct_Mapping_Type', + SAGE_INTACCT_USER_DIMENSIONS: 'Policy_Accounting_Sage_Intacct_User_Dimensions', + SAGE_INTACCT_ADD_USER_DIMENSION: 'Policy_Accounting_Sage_Intacct_Add_User_Dimension', + SAGE_INTACCT_EDIT_USER_DIMENSION: 'Policy_Accounting_Sage_Intacct_Edit_User_Dimension', + 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', @@ -313,11 +353,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', @@ -355,6 +397,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/index.tsx b/src/components/Attachments/AttachmentCarousel/Pager/index.tsx index f16ba2c53ae8..c32919ecff6e 100644 --- a/src/components/Attachments/AttachmentCarousel/Pager/index.tsx +++ b/src/components/Attachments/AttachmentCarousel/Pager/index.tsx @@ -66,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; }, []); diff --git a/src/components/Attachments/AttachmentCarousel/index.native.tsx b/src/components/Attachments/AttachmentCarousel/index.native.tsx index 243fc52f1f5d..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 */ diff --git a/src/components/Attachments/AttachmentCarousel/index.tsx b/src/components/Attachments/AttachmentCarousel/index.tsx index 8f4a4446df99..f7ef2c6529ce 100644 --- a/src/components/Attachments/AttachmentCarousel/index.tsx +++ b/src/components/Attachments/AttachmentCarousel/index.tsx @@ -115,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 */ @@ -135,7 +135,7 @@ function AttachmentCarousel({report, reportActions, parentReportActions, source, return; } - const item: Attachment = entry.item; + const item = entry.item as Attachment; if (entry.index !== null) { setPage(entry.index); setActiveSource(item.source); diff --git a/src/components/Attachments/AttachmentCarousel/useCarouselArrows.ts b/src/components/Attachments/AttachmentCarousel/useCarouselArrows.ts index a7ce0f93114b..ed195fd943f1 100644 --- a/src/components/Attachments/AttachmentCarousel/useCarouselArrows.ts +++ b/src/components/Attachments/AttachmentCarousel/useCarouselArrows.ts @@ -45,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 index d516879322ea..cc2c3c5c8229 100644 --- a/src/components/Attachments/AttachmentCarousel/useCarouselContextEvents.ts +++ b/src/components/Attachments/AttachmentCarousel/useCarouselContextEvents.ts @@ -39,6 +39,7 @@ function useCarouselContextEvents(setShouldShowArrows: (show?: SetStateAction 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/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.native.tsx b/src/components/ConnectToQuickbooksOnlineButton/index.native.tsx index bd9b623bcfb4..2b2c53eaaa18 100644 --- a/src/components/ConnectToQuickbooksOnlineButton/index.native.tsx +++ b/src/components/ConnectToQuickbooksOnlineButton/index.native.tsx @@ -13,6 +13,7 @@ import useNetwork from '@hooks/useNetwork'; import useThemeStyles from '@hooks/useThemeStyles'; import {removePolicyConnection} from '@libs/actions/connections'; import getQuickBooksOnlineSetupLink from '@libs/actions/connections/QuickBooksOnline'; +import * as PolicyAction from '@userActions/Policy/Policy'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Session} from '@src/types/onyx'; @@ -49,6 +50,8 @@ function ConnectToQuickbooksOnlineButton({ setIsDisconnectModalOpen(true); return; } + // Since QBO doesn't support Taxes, we should disable them from the LHN when connecting to QBO + PolicyAction.enablePolicyTaxes(policyID, false); setWebViewOpen(true); }} text={translate('workspace.accounting.setup')} @@ -59,6 +62,8 @@ function ConnectToQuickbooksOnlineButton({ {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); setIsDisconnectModalOpen(false); setWebViewOpen(true); diff --git a/src/components/ConnectionLayout.tsx b/src/components/ConnectionLayout.tsx index adb607c8e98b..3809b4f4f110 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; + + /** Whether the screen should load for an empty connection */ + shouldLoadForEmptyConnection?: 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, + shouldLoadForEmptyConnection = false, + onBackButtonPress = () => Navigation.goBack(), + shouldBeBlocked = false, }: ConnectionLayoutProps) { const {translate} = useLocalize(); @@ -115,12 +122,14 @@ function ConnectionLayout({ [title, titleStyle, children, titleAlreadyTranslated], ); + const shouldBlockByConnection = shouldLoadForEmptyConnection ? !isConnectionEmpty : isConnectionEmpty; + return ( 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/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/FocusTrapForModal/index.web.tsx b/src/components/FocusTrap/FocusTrapForModal/index.web.tsx index be5da8c49a78..00dcedd32aa2 100644 --- a/src/components/FocusTrap/FocusTrapForModal/index.web.tsx +++ b/src/components/FocusTrap/FocusTrapForModal/index.web.tsx @@ -1,6 +1,7 @@ import FocusTrap from 'focus-trap-react'; import React from 'react'; import sharedTrapStack from '@components/FocusTrap/sharedTrapStack'; +import ReportActionComposeFocusManager from '@libs/ReportActionComposeFocusManager'; import type FocusTrapForModalProps from './FocusTrapForModalProps'; function FocusTrapForModal({children, active}: FocusTrapForModalProps) { @@ -12,6 +13,12 @@ function FocusTrapForModal({children, active}: FocusTrapForModalProps) { clickOutsideDeactivates: true, initialFocus: false, fallbackFocus: document.body, + setReturnFocus: (element) => { + if (ReportActionComposeFocusManager.isFocused()) { + return false; + } + return element; + }, }} > {children} diff --git a/src/components/FocusTrap/FocusTrapForScreen/index.web.tsx b/src/components/FocusTrap/FocusTrapForScreen/index.web.tsx index d81293729b94..628a85c6d705 100644 --- a/src/components/FocusTrap/FocusTrapForScreen/index.web.tsx +++ b/src/components/FocusTrap/FocusTrapForScreen/index.web.tsx @@ -39,6 +39,7 @@ function FocusTrapForScreen({children}: FocusTrapProps) { useFocusEffect( useCallback(() => { + // eslint-disable-next-line react-compiler/react-compiler activeRouteName = route.name; }, [route]), ); diff --git a/src/components/FocusTrap/SCREENS_WITH_AUTOFOCUS.ts b/src/components/FocusTrap/SCREENS_WITH_AUTOFOCUS.ts index 7af327d35ac4..27eab777097f 100644 --- a/src/components/FocusTrap/SCREENS_WITH_AUTOFOCUS.ts +++ b/src/components/FocusTrap/SCREENS_WITH_AUTOFOCUS.ts @@ -9,8 +9,13 @@ 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, ]; diff --git a/src/components/Form/FormProvider.tsx b/src/components/Form/FormProvider.tsx index 9df94e4c6114..793154d95b02 100644 --- a/src/components/Form/FormProvider.tsx +++ b/src/components/Form/FormProvider.tsx @@ -73,6 +73,9 @@ type FormProviderProps = FormProvider /** Whether to apply flex to the submit button */ submitFlexEnabled?: boolean; + + /** Whether button is disabled */ + isSubmitDisabled?: boolean; }; function FormProvider( @@ -176,7 +179,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 +242,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/FormWrapper.tsx b/src/components/Form/FormWrapper.tsx index 5c74fd466a15..77ef44343792 100644 --- a/src/components/Form/FormWrapper.tsx +++ b/src/components/Form/FormWrapper.tsx @@ -38,6 +38,9 @@ type FormWrapperProps = ChildrenProps & /** Assuming refs are React refs */ inputRefs: RefObject; + /** Whether the submit button is disabled */ + isSubmitDisabled?: boolean; + /** Callback to submit the form */ onSubmit: () => void; }; @@ -57,9 +60,11 @@ function FormWrapper({ enabledWhenOffline, isSubmitActionDangerous = false, formID, + shouldUseScrollView = true, scrollContextEnabled = false, shouldHideFixErrorsAlert = false, disablePressOnEnter = true, + isSubmitDisabled = false, }: FormWrapperProps) { const styles = useThemeStyles(); const formRef = useRef(null); @@ -108,6 +113,7 @@ function FormWrapper({ {isSubmitButtonVisible && ( {({safeAreaPaddingBottomStyle}) => diff --git a/src/components/Form/InputWrapper.tsx b/src/components/Form/InputWrapper.tsx index b5535a2fe6c1..c966dd4456e9 100644 --- a/src/components/Form/InputWrapper.tsx +++ b/src/components/Form/InputWrapper.tsx @@ -54,7 +54,7 @@ function computeComponentSpecificRegistrationParams({ shouldSetTouchedOnBlurOnly: false, // Forward the originally provided value blurOnSubmit, - shouldSubmitForm: false, + shouldSubmitForm: !!shouldSubmitForm, }; } diff --git a/src/components/Form/types.ts b/src/components/Form/types.ts index afbe2bb124b5..5f56bbeceea6 100644 --- a/src/components/Form/types.ts +++ b/src/components/Form/types.ts @@ -20,6 +20,10 @@ import type TextInput from '@components/TextInput'; import type TextPicker from '@components/TextPicker'; import type ValuePicker from '@components/ValuePicker'; import type BusinessTypePicker from '@pages/ReimbursementAccount/BusinessInfo/substeps/TypeBusiness/BusinessTypePicker'; +import type DimensionTypeSelector from '@pages/workspace/accounting/intacct/import/DimensionTypeSelector'; +import type NetSuiteCustomFieldMappingPicker from '@pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldNew/NetSuiteCustomFieldMappingPicker'; +import type NetSuiteCustomListPicker from '@pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldNew/NetSuiteCustomListPicker'; +import type NetSuiteMenuWithTopDescriptionForm from '@pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldNew/NetSuiteMenuWithTopDescriptionForm'; import type {Country} from '@src/CONST'; import type {OnyxFormKey, OnyxValues} from '@src/ONYXKEYS'; import type {BaseForm} from '@src/types/form/Form'; @@ -39,6 +43,7 @@ type ValidInputs = | typeof CurrencySelector | typeof AmountForm | typeof BusinessTypePicker + | typeof DimensionTypeSelector | typeof StateSelector | typeof RoomNameInput | typeof ValuePicker @@ -47,7 +52,10 @@ type ValidInputs = | typeof AmountPicker | typeof TextPicker | typeof AddPlaidBankAccount - | typeof EmojiPickerButtonDropdown; + | typeof EmojiPickerButtonDropdown + | typeof NetSuiteCustomListPicker + | typeof NetSuiteCustomFieldMappingPicker + | typeof NetSuiteMenuWithTopDescriptionForm; type ValueTypeKey = 'string' | 'boolean' | 'date' | 'country' | 'reportFields' | 'disabledListValues'; type ValueTypeMap = { @@ -126,6 +134,9 @@ type FormProps = { /** Whether ScrollWithContext should be used instead of regular ScrollView. Set to true when there's a nested Picker component in Form. */ scrollContextEnabled?: boolean; + /** Whether to use ScrollView */ + shouldUseScrollView?: boolean; + /** Container styles */ style?: StyleProp; 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/HybridAppMiddleware.tsx b/src/components/HybridAppMiddleware.tsx deleted file mode 100644 index 5c6934f4fc3d..000000000000 --- a/src/components/HybridAppMiddleware.tsx +++ /dev/null @@ -1,108 +0,0 @@ -import {useNavigation} from '@react-navigation/native'; -import type {StackNavigationProp} from '@react-navigation/stack'; -import React, {useCallback, useEffect, useMemo, useState} from 'react'; -import {NativeModules} from 'react-native'; -import useSplashScreen from '@hooks/useSplashScreen'; -import BootSplash from '@libs/BootSplash'; -import Log from '@libs/Log'; -import Navigation from '@libs/Navigation/Navigation'; -import type {RootStackParamList} from '@libs/Navigation/types'; -import * as Welcome from '@userActions/Welcome'; -import CONST from '@src/CONST'; -import type {Route} from '@src/ROUTES'; - -type HybridAppMiddlewareProps = { - children: React.ReactNode; -}; - -type HybridAppMiddlewareContextType = { - navigateToExitUrl: (exitUrl: Route) => void; - showSplashScreenOnNextStart: () => void; -}; -const HybridAppMiddlewareContext = React.createContext({ - navigateToExitUrl: () => {}, - showSplashScreenOnNextStart: () => {}, -}); - -/* - * HybridAppMiddleware is responsible for handling BootSplash visibility correctly. - * It is crucial to make transitions between OldDot and NewDot look smooth. - */ -function HybridAppMiddleware(props: HybridAppMiddlewareProps) { - const {isSplashHidden, setIsSplashHidden} = useSplashScreen(); - const [startedTransition, setStartedTransition] = useState(false); - const [finishedTransition, setFinishedTransition] = useState(false); - const navigation = useNavigation>(); - - /* - * Handles navigation during transition from OldDot. For ordinary NewDot app it is just pure navigation. - */ - const navigateToExitUrl = useCallback((exitUrl: Route) => { - if (NativeModules.HybridAppModule) { - setStartedTransition(true); - Log.info(`[HybridApp] Started transition to ${exitUrl}`, true); - } - - Navigation.navigate(exitUrl); - }, []); - - /** - * This function only affects iOS. If during a single app lifecycle we frequently transition from OldDot to NewDot, - * we need to artificially show the bootsplash because the app is only booted once. - */ - const showSplashScreenOnNextStart = useCallback(() => { - setIsSplashHidden(false); - setStartedTransition(false); - setFinishedTransition(false); - }, [setIsSplashHidden]); - - useEffect(() => { - if (!finishedTransition || isSplashHidden) { - return; - } - - Log.info('[HybridApp] Finished transtion', true); - BootSplash.hide().then(() => { - setIsSplashHidden(true); - Log.info('[HybridApp] Handling onboarding flow', true); - Welcome.handleHybridAppOnboarding(); - }); - }, [finishedTransition, isSplashHidden, setIsSplashHidden]); - - useEffect(() => { - if (!startedTransition) { - return; - } - - // On iOS, the transitionEnd event doesn't trigger some times. As such, we need to set a timeout. - const timeout = setTimeout(() => { - setFinishedTransition(true); - }, CONST.SCREEN_TRANSITION_END_TIMEOUT); - - const unsubscribeTransitionEnd = navigation.addListener('transitionEnd', () => { - clearTimeout(timeout); - setFinishedTransition(true); - }); - - return () => { - clearTimeout(timeout); - unsubscribeTransitionEnd(); - }; - }, [navigation, startedTransition]); - - const contextValue = useMemo( - () => ({ - navigateToExitUrl, - showSplashScreenOnNextStart, - }), - [navigateToExitUrl, showSplashScreenOnNextStart], - ); - - return {props.children}; -} - -HybridAppMiddleware.displayName = 'HybridAppMiddleware'; - -export default HybridAppMiddleware; -export type {HybridAppMiddlewareContextType}; -export {HybridAppMiddlewareContext}; diff --git a/src/components/HybridAppMiddleware/index.ios.tsx b/src/components/HybridAppMiddleware/index.ios.tsx new file mode 100644 index 000000000000..5b06e5626c6e --- /dev/null +++ b/src/components/HybridAppMiddleware/index.ios.tsx @@ -0,0 +1,130 @@ +import type React from 'react'; +import {useContext, useEffect, useState} from 'react'; +import {NativeEventEmitter, NativeModules} from 'react-native'; +import type {NativeModule} from 'react-native'; +import {useOnyx} from 'react-native-onyx'; +import {InitialURLContext} from '@components/InitialURLContextProvider'; +import useExitTo from '@hooks/useExitTo'; +import useSplashScreen from '@hooks/useSplashScreen'; +import BootSplash from '@libs/BootSplash'; +import Log from '@libs/Log'; +import Navigation from '@libs/Navigation/Navigation'; +import * as SessionUtils from '@libs/SessionUtils'; +import * as Welcome from '@userActions/Welcome'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {HybridAppRoute, Route} from '@src/ROUTES'; + +type HybridAppMiddlewareProps = { + authenticated: boolean; + children: React.ReactNode; +}; + +/* + * HybridAppMiddleware is responsible for handling BootSplash visibility correctly. + * It is crucial to make transitions between OldDot and NewDot look smooth. + * The middleware assumes that the entry point for HybridApp is the /transition route. + */ +function HybridAppMiddleware({children, authenticated}: HybridAppMiddlewareProps) { + const {isSplashHidden, setIsSplashHidden} = useSplashScreen(); + const [startedTransition, setStartedTransition] = useState(false); + const [finishedTransition, setFinishedTransition] = useState(false); + + const initialURL = useContext(InitialURLContext); + const exitToParam = useExitTo(); + const [exitTo, setExitTo] = useState(); + + const [isAccountLoading] = useOnyx(ONYXKEYS.ACCOUNT, {selector: (account) => account?.isLoading ?? false}); + const [sessionEmail] = useOnyx(ONYXKEYS.SESSION, {selector: (session) => session?.email}); + + // In iOS, the HybridApp defines the `onReturnToOldDot` event. + // If we frequently transition from OldDot to NewDot during a single app lifecycle, + // we need to artificially display the bootsplash since the app is booted only once. + // Therefore, isSplashHidden needs to be updated at the appropriate time. + useEffect(() => { + if (!NativeModules.HybridAppModule) { + return; + } + const HybridAppEvents = new NativeEventEmitter(NativeModules.HybridAppModule as unknown as NativeModule); + const listener = HybridAppEvents.addListener(CONST.EVENTS.ON_RETURN_TO_OLD_DOT, () => { + Log.info('[HybridApp] `onReturnToOldDot` event received. Resetting state of HybridAppMiddleware', true); + setIsSplashHidden(false); + setStartedTransition(false); + setFinishedTransition(false); + setExitTo(undefined); + }); + + return () => { + listener.remove(); + }; + }, [setIsSplashHidden]); + + // Save `exitTo` when we reach /transition route. + // `exitTo` should always exist during OldDot -> NewDot transitions. + useEffect(() => { + if (!NativeModules.HybridAppModule || !exitToParam || exitTo) { + return; + } + + Log.info('[HybridApp] Saving `exitTo` for later', true, {exitTo: exitToParam}); + setExitTo(exitToParam); + + Log.info(`[HybridApp] Started transition`, true); + setStartedTransition(true); + }, [exitTo, exitToParam]); + + useEffect(() => { + if (!startedTransition || finishedTransition) { + return; + } + + const transitionURL = NativeModules.HybridAppModule ? `${CONST.DEEPLINK_BASE_URL}${initialURL ?? ''}` : initialURL; + const isLoggingInAsNewUser = SessionUtils.isLoggingInAsNewUser(transitionURL ?? undefined, sessionEmail); + + // We need to wait with navigating to exitTo until all login-related actions are complete. + if (!authenticated || isLoggingInAsNewUser || isAccountLoading) { + return; + } + + if (exitTo) { + Navigation.isNavigationReady().then(() => { + // We need to remove /transition from route history. + // `useExitTo` returns undefined for routes other than /transition. + if (exitToParam) { + Log.info('[HybridApp] Removing /transition route from history', true); + Navigation.goBack(); + } + + Log.info('[HybridApp] Navigating to `exitTo` route', true, {exitTo}); + Navigation.navigate(Navigation.parseHybridAppUrl(exitTo)); + setExitTo(undefined); + + setTimeout(() => { + Log.info('[HybridApp] Setting `finishedTransition` to true', true); + setFinishedTransition(true); + }, CONST.SCREEN_TRANSITION_END_TIMEOUT); + }); + } + }, [authenticated, exitTo, exitToParam, finishedTransition, initialURL, isAccountLoading, sessionEmail, startedTransition]); + + useEffect(() => { + if (!finishedTransition || isSplashHidden) { + return; + } + + Log.info('[HybridApp] Finished transition, hiding BootSplash', true); + BootSplash.hide().then(() => { + setIsSplashHidden(true); + if (authenticated) { + Log.info('[HybridApp] Handling onboarding flow', true); + Welcome.handleHybridAppOnboarding(); + } + }); + }, [authenticated, finishedTransition, isSplashHidden, setIsSplashHidden]); + + return children; +} + +HybridAppMiddleware.displayName = 'HybridAppMiddleware'; + +export default HybridAppMiddleware; diff --git a/src/components/HybridAppMiddleware/index.tsx b/src/components/HybridAppMiddleware/index.tsx new file mode 100644 index 000000000000..b8c72d9200ac --- /dev/null +++ b/src/components/HybridAppMiddleware/index.tsx @@ -0,0 +1,107 @@ +import type React from 'react'; +import {useContext, useEffect, useState} from 'react'; +import {NativeModules} from 'react-native'; +import {useOnyx} from 'react-native-onyx'; +import {InitialURLContext} from '@components/InitialURLContextProvider'; +import useExitTo from '@hooks/useExitTo'; +import useSplashScreen from '@hooks/useSplashScreen'; +import BootSplash from '@libs/BootSplash'; +import Log from '@libs/Log'; +import Navigation from '@libs/Navigation/Navigation'; +import * as SessionUtils from '@libs/SessionUtils'; +import * as Welcome from '@userActions/Welcome'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {HybridAppRoute, Route} from '@src/ROUTES'; + +type HybridAppMiddlewareProps = { + authenticated: boolean; + children: React.ReactNode; +}; + +/* + * HybridAppMiddleware is responsible for handling BootSplash visibility correctly. + * It is crucial to make transitions between OldDot and NewDot look smooth. + * The middleware assumes that the entry point for HybridApp is the /transition route. + */ +function HybridAppMiddleware({children, authenticated}: HybridAppMiddlewareProps) { + const {isSplashHidden, setIsSplashHidden} = useSplashScreen(); + const [startedTransition, setStartedTransition] = useState(false); + const [finishedTransition, setFinishedTransition] = useState(false); + + const initialURL = useContext(InitialURLContext); + const exitToParam = useExitTo(); + const [exitTo, setExitTo] = useState(); + + const [isAccountLoading] = useOnyx(ONYXKEYS.ACCOUNT, {selector: (account) => account?.isLoading ?? false}); + const [sessionEmail] = useOnyx(ONYXKEYS.SESSION, {selector: (session) => session?.email}); + + // Save `exitTo` when we reach /transition route. + // `exitTo` should always exist during OldDot -> NewDot transitions. + useEffect(() => { + if (!NativeModules.HybridAppModule || !exitToParam || exitTo) { + return; + } + + Log.info('[HybridApp] Saving `exitTo` for later', true, {exitTo: exitToParam}); + setExitTo(exitToParam); + + Log.info(`[HybridApp] Started transition`, true); + setStartedTransition(true); + }, [exitTo, exitToParam]); + + useEffect(() => { + if (!startedTransition || finishedTransition) { + return; + } + + const transitionURL = NativeModules.HybridAppModule ? `${CONST.DEEPLINK_BASE_URL}${initialURL ?? ''}` : initialURL; + const isLoggingInAsNewUser = SessionUtils.isLoggingInAsNewUser(transitionURL ?? undefined, sessionEmail); + + // We need to wait with navigating to exitTo until all login-related actions are complete. + if (!authenticated || isLoggingInAsNewUser || isAccountLoading) { + return; + } + + if (exitTo) { + Navigation.isNavigationReady().then(() => { + // We need to remove /transition from route history. + // `useExitTo` returns undefined for routes other than /transition. + if (exitToParam) { + Log.info('[HybridApp] Removing /transition route from history', true); + Navigation.goBack(); + } + + Log.info('[HybridApp] Navigating to `exitTo` route', true, {exitTo}); + Navigation.navigate(Navigation.parseHybridAppUrl(exitTo)); + setExitTo(undefined); + + setTimeout(() => { + Log.info('[HybridApp] Setting `finishedTransition` to true', true); + setFinishedTransition(true); + }, CONST.SCREEN_TRANSITION_END_TIMEOUT); + }); + } + }, [authenticated, exitTo, exitToParam, finishedTransition, initialURL, isAccountLoading, sessionEmail, startedTransition]); + + useEffect(() => { + if (!finishedTransition || isSplashHidden) { + return; + } + + Log.info('[HybridApp] Finished transition, hiding BootSplash', true); + BootSplash.hide().then(() => { + setIsSplashHidden(true); + if (authenticated) { + Log.info('[HybridApp] Handling onboarding flow', true); + Welcome.handleHybridAppOnboarding(); + } + }); + }, [authenticated, finishedTransition, isSplashHidden, setIsSplashHidden]); + + return children; +} + +HybridAppMiddleware.displayName = 'HybridAppMiddleware'; + +export default HybridAppMiddleware; 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..7a8186d2f38e 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'; @@ -7,6 +8,7 @@ import ConciergeExclamation from '@assets/images/product-illustrations/concierge import CreditCardsBlue from '@assets/images/product-illustrations/credit-cards--blue.svg'; import EmptyStateExpenses from '@assets/images/product-illustrations/emptystate__expenses.svg'; import EmptyStateTravel from '@assets/images/product-illustrations/emptystate__travel.svg'; +import FolderWithPapers from '@assets/images/product-illustrations/folder-with-papers.svg'; import GpsTrackOrange from '@assets/images/product-illustrations/gps-track--orange.svg'; import Hands from '@assets/images/product-illustrations/home-illustration-hands.svg'; import InvoiceOrange from '@assets/images/product-illustrations/invoice--orange.svg'; @@ -91,6 +93,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 +179,7 @@ export { Binoculars, CompanyCard, ReceiptUpload, + ExpensifyCardIllustration, SplitBill, PiggyBank, Accounting, @@ -194,4 +198,6 @@ export { CheckmarkCircle, CreditCardEyes, LockClosedOrange, + FolderWithPapers, + 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/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 7703b804611a..431a12d00106 100644 --- a/src/components/LHNOptionsList/OptionRowLHN.tsx +++ b/src/components/LHNOptionsList/OptionRowLHN.tsx @@ -21,8 +21,8 @@ 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'; @@ -251,7 +251,7 @@ function OptionRowLHN({reportID, isFocused = false, onSelectRow = () => {}, 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 780c8c7d2ea4..6777bbf6c269 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,30 +84,21 @@ function MoneyReportHeader({policy, report: moneyRequestReport, transactionThrea const [shouldShowHoldMenu, setShouldShowHoldMenu] = useState(false); const {translate} = useLocalize(); const {isOffline} = useNetwork(); - const {isSmallScreenWidth, windowWidth} = useWindowDimensions(); + const {isSmallScreenWidth} = useWindowDimensions(); const {reimbursableSpend} = ReportUtils.getMoneyRequestSpendBreakdown(moneyRequestReport); - const isSettled = ReportUtils.isSettled(moneyRequestReport.reportID); - const isApproved = ReportUtils.isReportApproved(moneyRequestReport); - const isClosed = ReportUtils.isClosedReport(moneyRequestReport); const isOnHold = TransactionUtils.isOnHold(transaction); - const isScanning = TransactionUtils.hasReceipt(transaction) && TransactionUtils.isReceiptBeingScanned(transaction); const isDeletedParentAction = !!requestParentReportAction && ReportActionsUtils.isDeletedAction(requestParentReportAction); - const canHoldOrUnholdRequest = !isEmptyObject(transaction) && !isSettled && !isApproved && !isDeletedParentAction && !isClosed; // Only the requestor can delete the request, admins can only edit it. const isActionOwner = typeof requestParentReportAction?.actorAccountID === 'number' && typeof session?.accountID === 'number' && requestParentReportAction.actorAccountID === session?.accountID; const canDeleteRequest = isActionOwner && ReportUtils.canAddOrDeleteTransactions(moneyRequestReport) && !isDeletedParentAction; - const isPolicyAdmin = policy?.role === CONST.POLICY.ROLE.ADMIN; - const isApprover = ReportUtils.isMoneyRequestReport(moneyRequestReport) && moneyRequestReport?.managerID !== null && session?.accountID === moneyRequestReport?.managerID; const [isHoldMenuVisible, setIsHoldMenuVisible] = useState(false); const [paymentType, setPaymentType] = useState(); const [requestType, setRequestType] = useState(); const canAllowSettlement = ReportUtils.hasUpdatedTotal(moneyRequestReport, policy); const policyType = policy?.type; - const isPayer = ReportUtils.isPayer(session, moneyRequestReport); const isDraft = ReportUtils.isOpenExpenseReport(moneyRequestReport); - const [isConfirmModalVisible, setIsConfirmModalVisible] = useState(false); const navigateBackToAfterDelete = useRef(); const hasScanningReceipt = ReportUtils.getTransactionsWithReceipts(moneyRequestReport?.reportID).some((t) => TransactionUtils.isReceiptBeingScanned(t)); @@ -118,14 +107,6 @@ function MoneyReportHeader({policy, report: moneyRequestReport, transactionThrea // allTransactions in TransactionUtils might have stale data const hasOnlyHeldExpenses = ReportUtils.hasOnlyHeldExpenses(moneyRequestReport.reportID, transactions); - const cancelPayment = useCallback(() => { - if (!chatReport) { - return; - } - IOU.cancelPayment(moneyRequestReport, chatReport); - setIsConfirmModalVisible(false); - }, [moneyRequestReport, chatReport]); - const shouldShowPayButton = useMemo(() => IOU.canIOUBePaid(moneyRequestReport, chatReport, policy), [moneyRequestReport, chatReport, policy]); const shouldShowApproveButton = useMemo(() => IOU.canApproveIOU(moneyRequestReport, chatReport, policy), [moneyRequestReport, chatReport, policy]); @@ -147,7 +128,7 @@ function MoneyReportHeader({policy, report: moneyRequestReport, transactionThrea const displayedAmount = ReportUtils.hasHeldExpenses(moneyRequestReport.reportID) && canAllowSettlement ? nonHeldAmount : formattedAmount; const isMoreContentShown = shouldShowNextStep || shouldShowStatusBar || (shouldShowAnyButton && shouldUseNarrowLayout); - const confirmPayment = (type?: PaymentMethodType | undefined, payAsBusiness?: boolean) => { + const confirmPayment = (type?: PaymentMethodType | undefined) => { if (!type || !chatReport) { return; } @@ -156,7 +137,7 @@ function MoneyReportHeader({policy, report: moneyRequestReport, transactionThrea if (ReportUtils.hasHeldExpenses(moneyRequestReport.reportID)) { setIsHoldMenuVisible(true); } else if (ReportUtils.isInvoiceReport(moneyRequestReport)) { - IOU.payInvoice(type, chatReport, moneyRequestReport, payAsBusiness); + IOU.payInvoice(type, chatReport, moneyRequestReport); } else { IOU.payMoneyRequest(type, chatReport, moneyRequestReport, true); } @@ -198,22 +179,6 @@ function MoneyReportHeader({policy, report: moneyRequestReport, transactionThrea TransactionActions.markAsCash(iouTransactionID, reportID); }, [requestParentReportAction, transactionThreadReport?.reportID]); - const changeMoneyRequestStatus = () => { - if (!transactionThreadReport) { - return; - } - const iouTransactionID = ReportActionsUtils.isMoneyRequestAction(requestParentReportAction) - ? ReportActionsUtils.getOriginalMessage(requestParentReportAction)?.IOUTransactionID ?? '-1' - : '-1'; - - if (isOnHold) { - IOU.unholdRequest(iouTransactionID, transactionThreadReport.reportID); - } else { - const activeRoute = encodeURIComponent(Navigation.getActiveRouteWithoutParams()); - Navigation.navigate(ROUTES.MONEY_REQUEST_HOLD_REASON.getRoute(policy?.type ?? CONST.POLICY.TYPE.PERSONAL, iouTransactionID, transactionThreadReport.reportID, activeRoute)); - } - }; - const getStatusIcon: (src: IconAsset) => React.ReactNode = (src) => ( changeMoneyRequestStatus(), - }); - } - if (!isOnHold && (isRequestIOU || canModifyStatus) && !isScanning && !isInvoiceReport) { - threeDotsMenuItems.push({ - icon: Expensicons.Stopwatch, - text: translate('iou.hold'), - onSelected: () => changeMoneyRequestStatus(), - }); - } - } - useEffect(() => { if (isLoadingHoldUseExplained) { return; @@ -291,23 +233,6 @@ function MoneyReportHeader({policy, report: moneyRequestReport, transactionThrea IOU.dismissHoldUseExplanation(); }; - if (isPayer && isSettled && ReportUtils.isExpenseReport(moneyRequestReport)) { - threeDotsMenuItems.push({ - icon: Expensicons.Trashcan, - text: translate('iou.cancelPayment'), - onSelected: () => setIsConfirmModalVisible(true), - }); - } - - // If the report supports adding transactions to it, then it also supports deleting transactions from it. - if (canDeleteRequest && !isEmptyObject(transactionThreadReport)) { - threeDotsMenuItems.push({ - icon: Expensicons.Trashcan, - text: translate('reportActionContextMenu.deleteAction', {action: requestParentReportAction}), - onSelected: () => setIsDeleteRequestModalVisible(true), - }); - } - useEffect(() => { if (canDeleteRequest) { return; @@ -328,9 +253,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 && ( @@ -440,17 +362,6 @@ function MoneyReportHeader({policy, report: moneyRequestReport, transactionThrea transactionCount={transactionIDs.length} /> )} - setIsConfirmModalVisible(false)} - prompt={translate('iou.cancelPaymentConfirmation')} - confirmText={translate('iou.cancelPayment')} - cancelText={translate('common.dismiss')} - danger - shouldEnableNewFocusManagement - /> 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 7e6682492eb2..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]); /** 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 && ( void) => { stopAnimation(); + // eslint-disable-next-line react-compiler/react-compiler offsetX.value = 0; offsetY.value = 0; pinchScale.value = 1; diff --git a/src/components/MultiGestureCanvas/usePanGesture.ts b/src/components/MultiGestureCanvas/usePanGesture.ts index 636913fdf05d..fa27e48eea4c 100644 --- a/src/components/MultiGestureCanvas/usePanGesture.ts +++ b/src/components/MultiGestureCanvas/usePanGesture.ts @@ -116,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], 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 ebb29198bda7..703bb5a5b14e 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 154f5c1e1cd3..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); 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 ( ); 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 9693b982ec4a..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], ); @@ -138,7 +138,6 @@ function ReportPreview({ const moneyRequestComment = action?.childLastMoneyRequestComment ?? ''; const isPolicyExpenseChat = ReportUtils.isPolicyExpenseChat(chatReport); - const isInvoiceRoom = ReportUtils.isInvoiceRoom(chatReport); const isOpenExpenseReport = isPolicyExpenseChat && ReportUtils.isOpenExpenseReport(iouReport); const isApproved = ReportUtils.isReportApproved(iouReport, action); @@ -178,7 +177,7 @@ function ReportPreview({ [chatReport?.isOwnPolicyExpenseChat, policy?.harvesting?.enabled], ); - const confirmPayment = (type: PaymentMethodType | undefined, payAsBusiness?: boolean) => { + const confirmPayment = (type: PaymentMethodType | undefined) => { if (!type) { return; } @@ -188,7 +187,7 @@ function ReportPreview({ setIsHoldMenuVisible(true); } else if (chatReport && iouReport) { if (ReportUtils.isInvoiceReport(iouReport)) { - IOU.payInvoice(type, chatReport, iouReport, payAsBusiness); + IOU.payInvoice(type, chatReport, iouReport); } else { IOU.payMoneyRequest(type, chatReport, iouReport); } @@ -247,16 +246,7 @@ function ReportPreview({ if (isScanning) { return translate('common.receipt'); } - - let payerOrApproverName; - if (isPolicyExpenseChat) { - payerOrApproverName = ReportUtils.getPolicyName(chatReport); - } else if (isInvoiceRoom) { - payerOrApproverName = ReportUtils.getInvoicePayerName(chatReport); - } else { - payerOrApproverName = ReportUtils.getDisplayNameForParticipant(managerID, true); - } - + let payerOrApproverName = isPolicyExpenseChat ? ReportUtils.getPolicyName(chatReport) : ReportUtils.getDisplayNameForParticipant(managerID, true); if (isApproved) { return translate('iou.managerApproved', {manager: payerOrApproverName}); } diff --git a/src/components/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/SearchContext.tsx b/src/components/Search/SearchContext.tsx new file mode 100644 index 000000000000..3911780d3965 --- /dev/null +++ b/src/components/Search/SearchContext.tsx @@ -0,0 +1,58 @@ +import React, {useCallback, useContext, useMemo, useState} from 'react'; +import type ChildrenProps from '@src/types/utils/ChildrenProps'; +import type {SearchContext} from './types'; + +const defaultSearchContext = { + currentSearchHash: -1, + selectedTransactionIDs: [], + setCurrentSearchHash: () => {}, + setSelectedTransactionIds: () => {}, +}; + +const Context = React.createContext(defaultSearchContext); + +function SearchContextProvider({children}: ChildrenProps) { + const [searchContextData, setSearchContextData] = useState>({ + currentSearchHash: defaultSearchContext.currentSearchHash, + selectedTransactionIDs: defaultSearchContext.selectedTransactionIDs, + }); + + const setCurrentSearchHash = useCallback( + (searchHash: number) => { + setSearchContextData({ + ...searchContextData, + currentSearchHash: searchHash, + }); + }, + [searchContextData], + ); + + const setSelectedTransactionIds = useCallback( + (selectedTransactionIDs: string[]) => { + setSearchContextData({ + ...searchContextData, + selectedTransactionIDs, + }); + }, + [searchContextData], + ); + + const searchContext = useMemo( + () => ({ + ...searchContextData, + setCurrentSearchHash, + setSelectedTransactionIds, + }), + [searchContextData, setCurrentSearchHash, setSelectedTransactionIds], + ); + + return {children}; +} + +function useSearchContext() { + return useContext(Context); +} + +SearchContextProvider.displayName = 'SearchContextProvider'; + +export {SearchContextProvider, useSearchContext}; diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index 6414501fb06d..fc5c23d5c9ec 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -13,7 +13,6 @@ 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 type {SearchColumnType, SortOrder} from '@libs/SearchUtils'; import * as SearchUtils from '@libs/SearchUtils'; import Navigation from '@navigation/Navigation'; import type {AuthScreensParamList} from '@navigation/types'; @@ -24,10 +23,11 @@ import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type SearchResults from '@src/types/onyx/SearchResults'; import type {SearchDataTypes, SearchQuery} from '@src/types/onyx/SearchResults'; -import {isEmptyObject} from '@src/types/utils/EmptyObject'; import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; +import {useSearchContext} from './SearchContext'; import SearchListWithHeader from './SearchListWithHeader'; import SearchPageHeader from './SearchPageHeader'; +import type {SearchColumnType, SortOrder} from './types'; type SearchProps = { query: SearchQuery; @@ -48,6 +48,7 @@ function Search({query, policyIDs, sortBy, sortOrder}: SearchProps) { const {isLargeScreenWidth} = useWindowDimensions(); const navigation = useNavigation>(); const lastSearchResultsRef = useRef>(); + const {setCurrentSearchHash} = useSearchContext(); const getItemHeight = useCallback( (item: TransactionListItemType | ReportListItemType) => { @@ -84,13 +85,14 @@ function Search({query, policyIDs, sortBy, sortOrder}: SearchProps) { return; } + setCurrentSearchHash(hash); 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 ( @@ -198,7 +200,7 @@ function Search({query, policyIDs, sortBy, sortOrder}: SearchProps) { getItemHeight={getItemHeight} shouldDebounceRowSelect shouldPreventDefaultFocusOnSelectRow={!DeviceCapabilities.canUseTouchScreen()} - listHeaderWrapperStyle={[styles.ph9, styles.pv3, styles.pb5]} + listHeaderWrapperStyle={[styles.ph8, styles.pv3, styles.pb5]} containerStyle={[styles.pv0]} showScrollIndicator={false} onEndReachedThreshold={0.75} diff --git a/src/components/Search/types.ts b/src/components/Search/types.ts index 3ebc2797947a..cff74fe08a0a 100644 --- a/src/components/Search/types.ts +++ b/src/components/Search/types.ts @@ -1,3 +1,6 @@ +import type {ValueOf} from 'react-native-gesture-handler/lib/typescript/typeUtils'; +import type CONST from '@src/CONST'; + /** Model of the selected transaction */ type SelectedTransactionInfo = { /** Whether the transaction is selected */ @@ -13,5 +16,14 @@ type SelectedTransactionInfo = { /** Model of selected results */ type SelectedTransactions = Record; -// eslint-disable-next-line import/prefer-default-export -export type {SelectedTransactionInfo, SelectedTransactions}; +type SortOrder = ValueOf; +type SearchColumnType = ValueOf; + +type SearchContext = { + currentSearchHash: number; + selectedTransactionIDs: string[]; + setCurrentSearchHash: (hash: number) => void; + setSelectedTransactionIds: (selectedTransactionIds: string[]) => void; +}; + +export type {SelectedTransactionInfo, SelectedTransactions, SearchColumnType, SortOrder, SearchContext}; 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 503f7d11d2da..8b6ba790e6b0 100644 --- a/src/components/SelectionList/BaseSelectionList.tsx +++ b/src/components/SelectionList/BaseSelectionList.tsx @@ -166,7 +166,7 @@ function BaseSelectionList( itemLayouts.push({length: fullItemHeight, offset}); offset += fullItemHeight; - if (item.isSelected && !selectedOptions.find((option) => option.text === item.text)) { + if (item.isSelected && !selectedOptions.find((option) => option.keyForList === item.keyForList)) { selectedOptions.push(item); } }); @@ -222,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 @@ -248,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], ); @@ -259,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 @@ -278,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]); /** @@ -337,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]); /** diff --git a/src/components/SelectionList/Search/ActionCell.tsx b/src/components/SelectionList/Search/ActionCell.tsx index 9e0599d839df..ad77070c1b99 100644 --- a/src/components/SelectionList/Search/ActionCell.tsx +++ b/src/components/SelectionList/Search/ActionCell.tsx @@ -1,43 +1,78 @@ -import React from 'react'; +import React, {useCallback} from 'react'; import {View} from 'react-native'; import Badge from '@components/Badge'; import Button from '@components/Button'; import * as Expensicons from '@components/Icon/Expensicons'; +import {useSearchContext} from '@components/Search/SearchContext'; import useLocalize from '@hooks/useLocalize'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; +import Navigation from '@navigation/Navigation'; import variables from '@styles/variables'; +import * as SearchActions from '@userActions/Search'; import CONST from '@src/CONST'; +import type {TranslationPaths} from '@src/languages/types'; +import ROUTES from '@src/ROUTES'; +import type {SearchTransactionAction} from '@src/types/onyx/SearchResults'; + +const actionTranslationsMap: Record = { + view: 'common.view', + review: 'common.review', + done: 'common.done', + paid: 'iou.settledExpensify', + hold: 'iou.hold', + unhold: 'iou.unhold', +}; type ActionCellProps = { - onButtonPress: () => void; - action?: string; + action?: SearchTransactionAction; + transactionID?: string; isLargeScreenWidth?: boolean; isSelected?: boolean; + goToItem: () => void; }; -function ActionCell({onButtonPress, action = CONST.SEARCH.ACTION_TYPES.VIEW, isLargeScreenWidth = true, isSelected = false}: ActionCellProps) { +function ActionCell({action = CONST.SEARCH.ACTION_TYPES.VIEW, transactionID, isLargeScreenWidth = true, isSelected = false, goToItem}: ActionCellProps) { const {translate} = useLocalize(); - const styles = useThemeStyles(); const theme = useTheme(); + const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); + const {currentSearchHash} = useSearchContext(); + + const onButtonPress = useCallback(() => { + if (!transactionID) { + return; + } + + if (action === CONST.SEARCH.ACTION_TYPES.HOLD) { + Navigation.navigate(ROUTES.TRANSACTION_HOLD_REASON_RHP.getRoute(CONST.SEARCH.TAB.ALL, transactionID)); + } else if (action === CONST.SEARCH.ACTION_TYPES.UNHOLD) { + SearchActions.unholdMoneyRequestOnSearch(currentSearchHash, [transactionID]); + } + }, [action, currentSearchHash, transactionID]); + + if (!isLargeScreenWidth) { + return null; + } + + const text = translate(actionTranslationsMap[action]); + 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'; return ( + ); + } + return (