diff --git a/android/app/build.gradle b/android/app/build.gradle index 64e6ec12309b..9d6aed6e96c9 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -110,8 +110,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1009005900 - versionName "9.0.59-0" + versionCode 1009006100 + versionName "9.0.61-0" // 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/attachment-not-found.svg b/assets/images/attachment-not-found.svg new file mode 100644 index 000000000000..25da973ce9cb --- /dev/null +++ b/assets/images/attachment-not-found.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/contributingGuides/CONTRIBUTING.md b/contributingGuides/CONTRIBUTING.md index be71cd4e115a..0a9417820190 100644 --- a/contributingGuides/CONTRIBUTING.md +++ b/contributingGuides/CONTRIBUTING.md @@ -11,6 +11,7 @@ You can create as many accounts as needed in order to test your changes directly 1. When testing chat functionality in the app please do this between accounts you or your fellow contributors own - **do not test chatting with Concierge**, as this diverts to our customer support team. Thank you. 2. A member of our customer onboarding team gets auto-assigned to every new policy created by a non-paying account to help them set up. Please **do not interact with these teams, ask for calls, or support on your issues.** If you do need to test functionality inside the defaultRooms (#admins & #announce) for any issues you’re working on, please let them know that you are a contributor and don’t need assistance. They will proceed to ignore the chat. +3. Please **do not post in any Expensify owned public room for testing** (e.g #exfy-roadmap, #new-expensify-feedback). These rooms include real customers and investors. You can create your own public rooms, or [use this test public room](https://staging.new.expensify.com/r/2091104345528462) on either staging or production. Thanks! #### Generating Multiple Test Accounts You can generate multiple test accounts by using a `+` postfix, for example if your email is test@test.com, you can create multiple New Expensify accounts connected to the same email address by using test+123@test.com, test+456@test.com, etc. diff --git a/contributingGuides/PERFORMANCE_METRICS.md b/contributingGuides/PERFORMANCE_METRICS.md index 6c40e346a3ce..ecebbaae4e0e 100644 --- a/contributingGuides/PERFORMANCE_METRICS.md +++ b/contributingGuides/PERFORMANCE_METRICS.md @@ -14,21 +14,16 @@ Project is using Firebase for tracking these metrics. However, not all of them a | `js_loaded` | ✅ | The time it takes for the JavaScript bundle to load.

**Platforms:** Android, iOS | **Android:** Starts in the `onCreate` method.

**iOS:** Starts in the AppDelegate's `didFinishLaunchingWithOptions` method. | Stops at the first render of the app via native module on the JS side. | | `_app_in_foreground` | ✅ | The time when the app is running in the foreground and available to the user.

**Platforms:** Android, iOS | **Android:** Starts when the first activity to reach the foreground has its `onResume()` method called.

**iOS:** Starts when the application receives the `UIApplicationDidBecomeActiveNotification` notification. | **Android:** Stops when the last activity to leave the foreground has its `onStop()` method called.

**iOS:** Stops when it receives the `UIApplicationWillResignActiveNotification` notification. | | `_app_in_background` | ✅ | Time when the app is running in the background.

**Platforms:** Android, iOS | **Android:** Starts when the last activity to leave the foreground has its `onStop()` method called.

**iOS:** Starts when the application receives the `UIApplicationWillResignActiveNotification` notification. | **Android:** Stops when the first activity to reach the foreground has its `onResume()` method called.

**iOS:** Stops when it receives the `UIApplicationDidBecomeActiveNotification` notification. | -| `homepage_initial_render` | ✅ | Time taken for the initial render of the app for a logged in user.

**Platforms:** All | Starts with the first render of the `AuthScreens` component. | Stops once the `AuthScreens` component is mounted. | -| `sidebar_loaded` | ❌ | Time taken for the Sidebar to load.

**Platforms:** All | Starts when the Sidebar is mounted. | Stops when the Splash Screen is hidden. | +| `sidebar_loaded` | ❌ | Time taken for the Sidebar to load.

**Platforms:** All | Starts when the Sidebar is mounted. | Stops when the LHN finishes laying out. | | `calc_most_recent_last_modified_action` | ✅ | Time taken to find the most recently modified report action or report.

**Platforms:** All | Starts when the app reconnects to the network | Ends when the app reconnects to the network and the most recent report action or report is found. | -| `search_render` | ✅ | Time taken to render the Chat Finder page.

**Platforms:** All | Starts when the Chat Finder icon in LHN is pressed. | Stops when the list of available options is rendered for the first time. | -| `load_search_options` | ✅ | Time taken to generate the list of options used in Chat Finder.

**Platforms:** All | Starts when the `getSearchOptions` function is called. | Stops when the list of available options is generated. | -| `search_filter_options` | ✅ | Time taken to filter search options in Chat Finder by given search value.

**Platforms:** All | Starts when user types something in the Chat Finder search input. | Stops when the list of filtered options is generated. | +| `open_search` | ✅ | Time taken to open up the Search Router.

**Platforms:** All | Starts when the Search Router icon in LHN is pressed. | Stops when the list of available options finishes laying out. | +| `load_search_options` | ✅ | Time taken to generate the list of options used in the Search Router.

**Platforms:** All | Starts when the `getSearchOptions` function is called. | Stops when the list of available options is generated. | +| `search_filter_options` | ✅ | Time taken to filter search options in the Search Router by the given search value.

**Platforms:** All | Starts when user types something in the Search Router search input. | Stops when the list of filtered options is generated. | | `trie_initialization` | ✅ | Time taken to build the emoji trie.

**Platforms:** All | Starts when emoji trie begins to build. | Stops when emoji trie building is complete. | -| `open_report` | ❌ | Time taken to open a report.

**Platforms:** All | Starts when the row in the `LHNOptionsList` is pressed. | Stops when the `ReportActionsList` finishes laying out. | -| `switch_report` | ✅ | Time taken to open report.

**Platforms:** All | Starts when the chat in the LHN is pressed. | Stops when the `ReportActionsList` finishes laying out. | +| `open_report` | ✅ | Time taken to open a report.

**Platforms:** All | Starts when the row in the `LHNOptionsList` is pressed. | Stops when the `ReportActionsList` finishes laying out. | | `open_report_from_preview` | ✅ | Time taken to open a report from preview.

(previously `switch_report_from_preview`)

**Platforms:** All | Starts when the user presses the Report Preview. | Stops when the `ReportActionsList` finishes laying out. | -| `switch_report_from_preview` | ❌ | **[REMOVED]** Time taken to open a report from preview. | Starts when the user presses the Report Preview. | Stops when the `ReportActionsList` finishes laying out. | -| `chat_render` | ✅ | Time taken to render the Report screen.

**Platforms:** All | Starts when the `ReportScreen` is being rendered for the first time. | Stops once the `ReportScreen` component is mounted. | -| `report_initial_render` | ❌ | Time taken to render the Report screen.

**Platforms:** All | Starts when the first item is rendered in the `LHNOptionsList`. | Stops when the `ReportActionsList` finishes laying out. | | `open_report_thread` | ✅ | Time taken to open a thread in a report.

**Platforms:** All | Starts when user presses Report Action Item. | Stops when the `ReportActionsList` finishes laying out. | -| `message_sent` | ❌ | Time taken to send a message.

**Platforms:** All | Starts when the new message is sent. | Stops when the message is being rendered in the chat. | +| `send_message` | ✅ | Time taken to send a message.

**Platforms:** All | Starts when the new message is sent. | Stops when the message is being rendered in the chat. | ## Documentation Maintenance @@ -46,4 +41,4 @@ To ensure this documentation remains accurate and useful, please adhere to the f ## Additional Resources - [Firebase Documentation](https://firebase.google.com/docs) -- [Firebase Performance Monitoring](https://firebase.google.com/docs/perf-mon) \ No newline at end of file +- [Firebase Performance Monitoring](https://firebase.google.com/docs/perf-mon) diff --git a/desktop/main.ts b/desktop/main.ts index 1221b05a8388..04aa3e1b478e 100644 --- a/desktop/main.ts +++ b/desktop/main.ts @@ -1,5 +1,5 @@ import {app, BrowserWindow, clipboard, dialog, ipcMain, Menu, shell} from 'electron'; -import type {BrowserView, MenuItem, MenuItemConstructorOptions, WebContents, WebviewTag} from 'electron'; +import type {BaseWindow, BrowserView, MenuItem, MenuItemConstructorOptions, WebContents, WebviewTag} from 'electron'; import contextMenu from 'electron-context-menu'; import log from 'electron-log'; import type {ElectronLog} from 'electron-log'; @@ -47,6 +47,8 @@ function pasteAsPlainText(browserWindow: BrowserWindow | BrowserView | WebviewTa const text = clipboard.readText(); if ('webContents' in browserWindow) { + // https://github.com/sindresorhus/electron-context-menu is passing in deprecated `BrowserView` to this function + // eslint-disable-next-line deprecation/deprecation browserWindow.webContents.insertText(text); } } @@ -107,7 +109,7 @@ process.argv.forEach((arg) => { return; } - expectedUpdateVersion = arg.substr(`${EXPECTED_UPDATE_VERSION_FLAG}=`.length); + expectedUpdateVersion = arg.slice(`${EXPECTED_UPDATE_VERSION_FLAG}=`.length); }); // Add the listeners and variables required to ensure that auto-updating @@ -132,7 +134,7 @@ const quitAndInstallWithUpdate = () => { }; /** Menu Item callback to trigger an update check */ -const manuallyCheckForUpdates = (menuItem?: MenuItem, browserWindow?: BrowserWindow) => { +const manuallyCheckForUpdates = (menuItem?: MenuItem, browserWindow?: BaseWindow) => { if (menuItem) { // Disable item until the check (and download) is complete // eslint-disable-next-line no-param-reassign -- menu item flags like enabled or visible can be dynamically toggled by mutating the object @@ -427,7 +429,7 @@ const mainWindow = (): Promise => { id: 'back', accelerator: process.platform === 'darwin' ? 'Cmd+[' : 'Shift+[', click: () => { - browserWindow.webContents.goBack(); + browserWindow.webContents.navigationHistory.goBack(); }, }, { @@ -435,14 +437,14 @@ const mainWindow = (): Promise => { visible: false, accelerator: process.platform === 'darwin' ? 'Cmd+Left' : 'Shift+Left', click: () => { - browserWindow.webContents.goBack(); + browserWindow.webContents.navigationHistory.goBack(); }, }, { id: 'forward', accelerator: process.platform === 'darwin' ? 'Cmd+]' : 'Shift+]', click: () => { - browserWindow.webContents.goForward(); + browserWindow.webContents.navigationHistory.goForward(); }, }, { @@ -450,7 +452,7 @@ const mainWindow = (): Promise => { visible: false, accelerator: process.platform === 'darwin' ? 'Cmd+Right' : 'Shift+Right', click: () => { - browserWindow.webContents.goForward(); + browserWindow.webContents.navigationHistory.goForward(); }, }, ], @@ -507,7 +509,7 @@ const mainWindow = (): Promise => { const denial = {action: 'deny'} as const; // Make sure local urls stay in electron perimeter - if (url.substr(0, 'file://'.length).toLowerCase() === 'file://') { + if (url.slice(0, 'file://'.length).toLowerCase() === 'file://') { return denial; } @@ -539,19 +541,19 @@ const mainWindow = (): Promise => { // Initiating a browser-back or browser-forward with mouse buttons should navigate history. browserWindow.on('app-command', (e, cmd) => { if (cmd === 'browser-backward') { - browserWindow.webContents.goBack(); + browserWindow.webContents.navigationHistory.goBack(); } if (cmd === 'browser-forward') { - browserWindow.webContents.goForward(); + browserWindow.webContents.navigationHistory.goForward(); } }); browserWindow.on('swipe', (e, direction) => { if (direction === 'left') { - browserWindow.webContents.goBack(); + browserWindow.webContents.navigationHistory.goBack(); } if (direction === 'right') { - browserWindow.webContents.goForward(); + browserWindow.webContents.navigationHistory.goForward(); } }); diff --git a/desktop/package-lock.json b/desktop/package-lock.json index 926fb1e24d22..a8bbd48c1818 100644 --- a/desktop/package-lock.json +++ b/desktop/package-lock.json @@ -27,6 +27,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", "engines": { "node": ">=8" } @@ -35,6 +36,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", "dependencies": { "color-convert": "^2.0.1" }, @@ -54,6 +56,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", + "license": "MIT", "engines": { "node": ">=8" } @@ -75,6 +78,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-2.1.0.tgz", "integrity": "sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==", + "license": "MIT", "dependencies": { "slice-ansi": "^3.0.0", "string-width": "^4.2.0" @@ -90,6 +94,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", "dependencies": { "color-name": "~1.1.4" }, @@ -100,7 +105,8 @@ "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==" + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" }, "node_modules/debug": { "version": "4.3.7", @@ -123,6 +129,7 @@ "version": "2.5.2", "resolved": "https://registry.npmjs.org/electron-context-menu/-/electron-context-menu-2.5.2.tgz", "integrity": "sha512-1cEQR6fA9ktFsRBc+eXPwvrOgAPytUD7rUV4iBAA5zTrLAPKokJ23xeMjcK2fjrDPrlFRBxcLz0KP+GUhMrSCQ==", + "license": "MIT", "dependencies": { "cli-truncate": "^2.1.0", "electron-dl": "^3.1.0", @@ -133,14 +140,18 @@ } }, "node_modules/electron-dl": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/electron-dl/-/electron-dl-3.3.0.tgz", - "integrity": "sha512-Zwaz/OMGPIfBLV2SQH4sTsdDOs/U4y5AOHfremMBXEpjIxX+SiTx845DZAvJJwgb5hfowyWOBLiJhd/emBNLLQ==", + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/electron-dl/-/electron-dl-3.5.2.tgz", + "integrity": "sha512-i104cl+u8yJ0lhpRAtUWfeGuWuL1PL6TBiw2gLf0MMIBjfgE485Ags2mcySx4uWU9P9uj/vsD3jd7X+w1lzZxw==", + "license": "MIT", "dependencies": { "ext-name": "^5.0.0", "pupa": "^2.0.1", "unused-filename": "^2.1.0" }, + "engines": { + "node": ">=12" + }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } @@ -148,7 +159,8 @@ "node_modules/electron-is-dev": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/electron-is-dev/-/electron-is-dev-1.2.0.tgz", - "integrity": "sha512-R1oD5gMBPS7PVU8gJwH6CtT0e6VSoD0+SzSnYpNm+dBkcijgA+K7VAMHDfnRq/lkKPZArpzplTW6jfiMYosdzw==" + "integrity": "sha512-R1oD5gMBPS7PVU8gJwH6CtT0e6VSoD0+SzSnYpNm+dBkcijgA+K7VAMHDfnRq/lkKPZArpzplTW6jfiMYosdzw==", + "license": "MIT" }, "node_modules/electron-log": { "version": "4.4.8", @@ -174,12 +186,14 @@ "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" }, "node_modules/escape-goat": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/escape-goat/-/escape-goat-2.1.1.tgz", "integrity": "sha512-8/uIhbG12Csjy2JEW7D9pHbreaVaS/OpN3ycnyvElTdwM5n6GY6W6e2IPemfvGZeUMqZ9A/3GqIZMgKnBhAw/Q==", + "license": "MIT", "engines": { "node": ">=8" } @@ -188,6 +202,7 @@ "version": "2.2.2", "resolved": "https://registry.npmjs.org/ext-list/-/ext-list-2.2.2.tgz", "integrity": "sha512-u+SQgsubraE6zItfVA0tBuCBhfU9ogSRnsvygI7wht9TS510oLkBRXBsqopeUG/GBOIQyKZO9wjTqIu/sf5zFA==", + "license": "MIT", "dependencies": { "mime-db": "^1.28.0" }, @@ -199,6 +214,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/ext-name/-/ext-name-5.0.0.tgz", "integrity": "sha512-yblEwXAbGv1VQDmow7s38W77hzAgJAO50ztBLMcUyUBfxv1HC+LGwtiEN+Co6LtlqT/5uwVOxsD4TNIilWhwdQ==", + "license": "MIT", "dependencies": { "ext-list": "^2.0.0", "sort-keys-length": "^1.0.0" @@ -229,6 +245,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", "engines": { "node": ">=8" } @@ -236,7 +253,8 @@ "node_modules/is-plain-obj": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-1.1.0.tgz", - "integrity": "sha1-caUMhCnfync8kqOQpKA7OfzVHT4=", + "integrity": "sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg==", + "license": "MIT", "engines": { "node": ">=0.10.0" } @@ -300,7 +318,8 @@ "node_modules/modify-filename": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/modify-filename/-/modify-filename-1.1.0.tgz", - "integrity": "sha1-mi3sg4Bvuy2XXyK+7IWcoms5OqE=", + "integrity": "sha512-EickqnKq3kVVaZisYuCxhtKbZjInCuwgwZWyAmRIp1NTMhri7r3380/uqwrUHfaDiPzLVTuoNy4whX66bxPVog==", + "license": "MIT", "engines": { "node": ">=0.10.0" } @@ -320,6 +339,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "license": "MIT", "engines": { "node": ">=8" } @@ -328,6 +348,7 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/pupa/-/pupa-2.1.1.tgz", "integrity": "sha512-l1jNAspIBSFqbT+y+5FosojNpVpF94nlI+wDUpqP9enwOTfHx9f0gh5nB96vl+6yTpsJsypeNrwfzPrKuHB41A==", + "license": "MIT", "dependencies": { "escape-goat": "^2.0.0" }, @@ -357,6 +378,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-3.0.0.tgz", "integrity": "sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ==", + "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", "astral-regex": "^2.0.0", @@ -369,7 +391,8 @@ "node_modules/sort-keys": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/sort-keys/-/sort-keys-1.1.2.tgz", - "integrity": "sha1-RBttTTRnmPG05J6JIK37oOVD+a0=", + "integrity": "sha512-vzn8aSqKgytVik0iwdBEi+zevbTYZogewTUM6dtpmGwEcdzbub/TX4bCzRhebDCRC3QzXgJsLRKB2V/Oof7HXg==", + "license": "MIT", "dependencies": { "is-plain-obj": "^1.0.0" }, @@ -380,7 +403,8 @@ "node_modules/sort-keys-length": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/sort-keys-length/-/sort-keys-length-1.0.1.tgz", - "integrity": "sha1-nLb09OnkgVWmqgZx7dM2/xR5oYg=", + "integrity": "sha512-GRbEOUqCxemTAk/b32F2xa8wDTs+Z1QHOkbhJDQTvv/6G3ZkbJ+frYWsTcc7cBB3Fu4wy4XlLCuNtJuMn7Gsvw==", + "license": "MIT", "dependencies": { "sort-keys": "^1.0.0" }, @@ -392,6 +416,7 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", @@ -405,6 +430,7 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" }, @@ -429,6 +455,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/unused-filename/-/unused-filename-2.1.0.tgz", "integrity": "sha512-BMiNwJbuWmqCpAM1FqxCTD7lXF97AvfQC8Kr/DIeA6VtvhJaMDupZ82+inbjl5yVP44PcxOuCSxye1QMS0wZyg==", + "license": "MIT", "dependencies": { "modify-filename": "^1.1.0", "path-exists": "^4.0.0" @@ -518,9 +545,9 @@ } }, "electron-dl": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/electron-dl/-/electron-dl-3.3.0.tgz", - "integrity": "sha512-Zwaz/OMGPIfBLV2SQH4sTsdDOs/U4y5AOHfremMBXEpjIxX+SiTx845DZAvJJwgb5hfowyWOBLiJhd/emBNLLQ==", + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/electron-dl/-/electron-dl-3.5.2.tgz", + "integrity": "sha512-i104cl+u8yJ0lhpRAtUWfeGuWuL1PL6TBiw2gLf0MMIBjfgE485Ags2mcySx4uWU9P9uj/vsD3jd7X+w1lzZxw==", "requires": { "ext-name": "^5.0.0", "pupa": "^2.0.1", @@ -602,7 +629,7 @@ "is-plain-obj": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-1.1.0.tgz", - "integrity": "sha1-caUMhCnfync8kqOQpKA7OfzVHT4=" + "integrity": "sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg==" }, "js-yaml": { "version": "4.1.0", @@ -652,7 +679,7 @@ "modify-filename": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/modify-filename/-/modify-filename-1.1.0.tgz", - "integrity": "sha1-mi3sg4Bvuy2XXyK+7IWcoms5OqE=" + "integrity": "sha512-EickqnKq3kVVaZisYuCxhtKbZjInCuwgwZWyAmRIp1NTMhri7r3380/uqwrUHfaDiPzLVTuoNy4whX66bxPVog==" }, "ms": { "version": "2.1.3", @@ -700,7 +727,7 @@ "sort-keys": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/sort-keys/-/sort-keys-1.1.2.tgz", - "integrity": "sha1-RBttTTRnmPG05J6JIK37oOVD+a0=", + "integrity": "sha512-vzn8aSqKgytVik0iwdBEi+zevbTYZogewTUM6dtpmGwEcdzbub/TX4bCzRhebDCRC3QzXgJsLRKB2V/Oof7HXg==", "requires": { "is-plain-obj": "^1.0.0" } @@ -708,7 +735,7 @@ "sort-keys-length": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/sort-keys-length/-/sort-keys-length-1.0.1.tgz", - "integrity": "sha1-nLb09OnkgVWmqgZx7dM2/xR5oYg=", + "integrity": "sha512-GRbEOUqCxemTAk/b32F2xa8wDTs+Z1QHOkbhJDQTvv/6G3ZkbJ+frYWsTcc7cBB3Fu4wy4XlLCuNtJuMn7Gsvw==", "requires": { "sort-keys": "^1.0.0" } diff --git a/docs/_sass/_main.scss b/docs/_sass/_main.scss index 82446fe08b3a..bc2b9e8499fa 100644 --- a/docs/_sass/_main.scss +++ b/docs/_sass/_main.scss @@ -149,7 +149,7 @@ button, input, select, textarea { - line-height: 1.4; + line-height: 1.33; font-weight: 400; font-family: 'Expensify Neue', 'Helvetica Neue', 'Helvetica', Arial, sans-serif; font-size: 16px; @@ -374,6 +374,18 @@ button { } } + li { + padding-bottom: 4px; + } + + ol { + li { + ul { + padding-bottom: 0; + } + } + } + table { margin-bottom: 20px; border-radius: 8px; @@ -417,6 +429,12 @@ button { flex-wrap: wrap; } + h1, + h2, + h3 { + line-height: 1.2; + } + h1, summary { font-size: 1.5em; diff --git a/docs/articles/expensify-classic/bank-accounts-and-payments/bank-accounts/Enable-Global-Reimbursements.md b/docs/articles/expensify-classic/bank-accounts-and-payments/bank-accounts/Enable-Global-Reimbursements.md index 37d8d8bbe42b..02ee7b7ce04a 100644 --- a/docs/articles/expensify-classic/bank-accounts-and-payments/bank-accounts/Enable-Global-Reimbursements.md +++ b/docs/articles/expensify-classic/bank-accounts-and-payments/bank-accounts/Enable-Global-Reimbursements.md @@ -16,8 +16,6 @@ Before you can complete this process, you must first connect a **verified** U.S. Once your verified U.S. bank account has been added and verified, you can request that global reimbursements be enabled on your account. -Click the support icon in your Expensify account to inform your Setup Specialist, Account Manager, or Concierge that you’d like to enable global reimbursements. They will ask you to confirm the currencies of the bank accounts and determine if your account meets the criteria for global reimbursements. - ## Step 2: Re-verify the bank account 1. Hover over **Settings**, then click **Workspaces**. @@ -28,12 +26,6 @@ Click the support icon in your Expensify account to inform your Setup Specialist 6. Ensure that the reimbursement method is set to **Direct** and that the right bank account is selected. 7. Click the **Payments** tab on the left. 8. Click **Enable Global Reimbursement** next to the bank account. - -{% include info.html %} -This button may not appear for up to 60 minutes after the Expensify team confirms global reimbursements for your account. -{% include end-info.html %} - -{:start="9"} 9. Complete the International Reimbursement DocuSign form. Once the form is complete, it is automatically sent to our Compliance Team for review. Our Support Team will contact you with more details if additional information is required, which may include: @@ -43,11 +35,7 @@ Once the form is complete, it is automatically sent to our Compliance Team for r # For AUD, CAD, GBP, and EUR accounts -## Step 1: Request global reimbursements - -Click the support icon in your Expensify account to inform your Setup Specialist, Account Manager, or Concierge that you’d like to enable global reimbursements. They will ask you to confirm the currencies of the bank accounts and determine if your account meets the criteria for global reimbursements. - -## Step 2: Add the bank account +## Step 1: Add the bank account 1. Hover over **Settings**, then click **Workspaces**. 2. Select the workspace. @@ -55,12 +43,6 @@ Click the support icon in your Expensify account to inform your Setup Specialist 4. Ensure that the selected workspace currency matches your reimbursement bank account currency. 5. Click the **Reimbursements** tab on the left. 6. Set the reimbursement method to **Direct**. - -{% include info.html %} -This button may not appear for up to 60 minutes after the Expensify team confirms global reimbursements for your account. -{% include end-info.html %} - -{:start="7"} 7. Click **Add Business Bank Account**. 8. If necessary, click **Switch Country** to select the correct country if not automatically selected. 9. Enter the bank account details, then click **Save & Continue**. diff --git a/docs/articles/expensify-classic/connections/quickbooks-online/Configure-Quickbooks-Online.md b/docs/articles/expensify-classic/connections/quickbooks-online/Configure-Quickbooks-Online.md index 3fd1df0c0a1c..a6e19f8fd549 100644 --- a/docs/articles/expensify-classic/connections/quickbooks-online/Configure-Quickbooks-Online.md +++ b/docs/articles/expensify-classic/connections/quickbooks-online/Configure-Quickbooks-Online.md @@ -40,6 +40,7 @@ The following steps help you determine how data will be exported from Expensify - Journal Entries - This is a single itemized journal entry for each Expensify report. - _Non-reimbursable expenses_: Non-reimbursable expenses export to QuickBooks Online as: - Credit Card expenses - Each expense will be exported as a bank transaction with its transaction date. + - Note: The Expensify Card transactions will always export as Credit Card charges, even if the non-reimbursable setting is configured differently (such as a Vendor Bill.) - Debit Card Expenses - Each expense will be exported as a bank transaction with its transaction date. - Vendor Bills - A single detailed vendor bill is generated for each Expensify report. - If the accounting period is closed, the vendor bill will be posted on the first day of the next open period. If you choose to export non-reimbursable expenses as Vendor Bills, you can assign a default vendor to the bill. diff --git a/docs/articles/new-expensify/billing-and-subscriptions/Plan-types-and-pricing.md b/docs/articles/new-expensify/billing-and-subscriptions/Plan-types-and-pricing.md new file mode 100644 index 000000000000..bc39e33bab4a --- /dev/null +++ b/docs/articles/new-expensify/billing-and-subscriptions/Plan-types-and-pricing.md @@ -0,0 +1,48 @@ +--- +title: Expensify plan types and pricing +description: An overview of plan types and pricing +--- +
+ +Expensify offers plans and flexible pricing to cater to different business sizes and needs, whether you’re self-employed, part of a large organization, or anything in between. + +# Choosing the Right Plan + +Expensify offers two pricing plans: + + +| | Collect Plan | Control Plan | +|--------------------|---------------------------|---------------------------------------------------------| +| **Ideal for:** | Sole proprietors and small teams or businesses with 1-10 employees | Larger companies with 10-1000 employees and more complex expense management needs | +| **Pricing starts at:** | $5 USD per user/month on an annual subscription (Non-USD prices available in FAQ) | $9 USD per user/month on an annual subscription (Non-USD prices available in FAQ) | +| | ✔ Unlimited SmartScans and distance tracking | ✔ All Collect Plan features | +| | ✔ Expensify Cards with Smart Limits and cash back | ✔ Third-party card feeds and reconciliation | +| | ✔ Expense approvals | ✔ Integration with NetSuite, Sage Intacct, and QuickBooks Desktop | +| | ✔ Unlimited ACH reimbursement | ✔ Gusto, Zenefits, Certinia, and Workday sync | +| | ✔ Integration with QuickBooks Online and Xero | ✔ Multiple expense approvers | +| | | ✔ SAML/SSO for added security | +| | | ✔ Admin-enforced controls | + +Expensify Card usage on both plans generates 1% cash back with every swipe on US purchases --- no minimums necessary --- and 2% back if you spend $250k+/month across cards. + +# FAQ + +## How much does Expensify cost? + +The cost depends on your plan and subscription type. Expensify offers a 50% discount for annual subscriptions and up to another 50% discount for using Expensify Cards. Try out our [savings calculator](https://use.expensify.com/savings-calculator) for an easy estimate based on your numbers. + +## Does Expensify bill in non-USD currencies? + +Yes! Customers can pay for Expensify in AUD, GBP, or NZD in addition to USD. +- The Collect plan begins at A$14, £8, or NZ$16 per user/month on an annual subscription +- The Control plan begins at A$30, £14, or NZ$32 per user/month on an annual subscription + +## Is Expensify free for individuals? + +Yes! Individuals can use Expensify for free to track expenses. + +## How do I get more info about pricing? + +For customized information or help choosing the right plan, reach out to Expensify Concierge or email concierge@expensify.com. + +
diff --git a/docs/articles/new-expensify/chat/assign-a-task.md b/docs/articles/new-expensify/chat/assign-a-task.md new file mode 100644 index 000000000000..a553caf39366 --- /dev/null +++ b/docs/articles/new-expensify/chat/assign-a-task.md @@ -0,0 +1,25 @@ +--- +title: Assign a Task +description: Assign a task to a specific user within a chat +--- + +Tasks are a useful feature in New Expensify which allows the tracking and assignment of a task to a specific user (known as an Assignee) within a 1:1 or group chat. + +# How to create a Task + +1. Click on a chat +2. Click on the + button +3. Click on “Assign a Task” +4. Add a title (mandatory) and a description (optional) +5. You’ll then have the option to add an assignee to the Task. If you wish to utilise the task for tracking purposes, you can keep this unassigned. + +_Note: The Task will always be shared in the chat it was created in._ + +# To edit a Task + +Both the Task creator and the Assignee will have the option to: + +- Leave a comment in the task’s thread +- Reassign the Task to someone else by clicking on the task, then the sideways arrow next to the “Assignee” field, and selecting a new Assignee or typing their email. +- Edit any of the task’s details (i.e. title and description) by clicking on the task, and then the sideways arrow next to each field. +- Mark the Task as complete by either clicking on the checkbox or clicking on the Task, then clicking on the green “Mark as Complete” button. diff --git a/docs/articles/new-expensify/expensify-card/Add-Expensify-Card-to-Apple-or-Google-Pay.md b/docs/articles/new-expensify/expensify-card/Add-Expensify-Card-to-Apple-or-Google-Pay.md index 844a688e0011..3c147e33517b 100644 --- a/docs/articles/new-expensify/expensify-card/Add-Expensify-Card-to-Apple-or-Google-Pay.md +++ b/docs/articles/new-expensify/expensify-card/Add-Expensify-Card-to-Apple-or-Google-Pay.md @@ -9,7 +9,7 @@ You can use your Expensify Card for contactless in-person payments by adding it {% include selector.html values="mobile" %} {% include option.html value="mobile" %} -**Apple Pay** +### Apple Pay 1. Open the Apple Pay app. 2. Tap the + button. @@ -17,7 +17,7 @@ You can use your Expensify Card for contactless in-person payments by adding it 4. Tap **Continue**. 5. Follow the steps provided to add your virtual card. -**Google Pay** +### Google Pay 1. Open the Google Pay app. 2. Tap **Add to Wallet**. diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index e4f525da1e9f..537de56b131c 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -19,7 +19,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 9.0.59 + 9.0.61 CFBundleSignature ???? CFBundleURLTypes @@ -40,7 +40,7 @@ CFBundleVersion - 9.0.59.0 + 9.0.61.0 FullStory OrgId diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index 96070daa066c..328e27f2578f 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -15,10 +15,10 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 9.0.59 + 9.0.61 CFBundleSignature ???? CFBundleVersion - 9.0.59.0 + 9.0.61.0 diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index e0bef4291004..7efe1888d4ae 100644 --- a/ios/NotificationServiceExtension/Info.plist +++ b/ios/NotificationServiceExtension/Info.plist @@ -11,9 +11,9 @@ CFBundleName $(PRODUCT_NAME) CFBundleShortVersionString - 9.0.59 + 9.0.61 CFBundleVersion - 9.0.59.0 + 9.0.61.0 NSExtension NSExtensionPointIdentifier diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 5268e5e9fb24..18d030a38ce9 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -2391,7 +2391,7 @@ PODS: - RNGoogleSignin (10.0.1): - GoogleSignIn (~> 7.0) - React-Core - - RNLiveMarkdown (0.1.180): + - RNLiveMarkdown (0.1.183): - DoubleConversion - glog - hermes-engine @@ -2411,9 +2411,9 @@ PODS: - ReactCodegen - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - - RNLiveMarkdown/newarch (= 0.1.180) + - RNLiveMarkdown/newarch (= 0.1.183) - Yoga - - RNLiveMarkdown/newarch (0.1.180): + - RNLiveMarkdown/newarch (0.1.183): - DoubleConversion - glog - hermes-engine @@ -3264,7 +3264,7 @@ SPEC CHECKSUMS: RNFS: 4ac0f0ea233904cb798630b3c077808c06931688 RNGestureHandler: 8781e2529230a1bc3ea8d75e5c3cd071b6c6aed7 RNGoogleSignin: ccaa4a81582cf713eea562c5dd9dc1961a715fd0 - RNLiveMarkdown: fc07b203a3ed832e2e5d3950e69cd4fc3b0568b6 + RNLiveMarkdown: fa9c6451960d09209bb5698745a0a66330ec53cc RNLocalize: d4b8af4e442d4bcca54e68fc687a2129b4d71a81 rnmapbox-maps: 460d6ff97ae49c7d5708c3212c6521697c36a0c4 RNPermissions: 0b1429b55af59d1d08b75a8be2459f65a8ac3f28 diff --git a/package-lock.json b/package-lock.json index 594888c76972..b318c1a7f31c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,17 +1,17 @@ { "name": "new.expensify", - "version": "9.0.59-0", + "version": "9.0.61-0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "9.0.59-0", + "version": "9.0.61-0", "hasInstallScript": true, "license": "MIT", "dependencies": { "@dotlottie/react-player": "^1.6.3", - "@expensify/react-native-live-markdown": "0.1.180", + "@expensify/react-native-live-markdown": "0.1.183", "@expo/metro-runtime": "~3.2.3", "@firebase/app": "^0.10.10", "@firebase/performance": "^0.6.8", @@ -215,7 +215,7 @@ "csv-writer": "^1.6.0", "diff-so-fancy": "^1.3.0", "dotenv": "^16.0.3", - "electron": "^29.4.6", + "electron": "^32.2.3", "electron-builder": "25.0.0", "eslint": "^8.57.0", "eslint-config-airbnb-typescript": "^18.0.0", @@ -3631,9 +3631,9 @@ } }, "node_modules/@expensify/react-native-live-markdown": { - "version": "0.1.180", - "resolved": "https://registry.npmjs.org/@expensify/react-native-live-markdown/-/react-native-live-markdown-0.1.180.tgz", - "integrity": "sha512-toyFMl5nXQiC2lY6x/bGagsXaeCevZjVuebnClwVZskrPMI65o8OH/Y1VvTly9eNWD04Br++ANmOPJZYMisEiQ==", + "version": "0.1.183", + "resolved": "https://registry.npmjs.org/@expensify/react-native-live-markdown/-/react-native-live-markdown-0.1.183.tgz", + "integrity": "sha512-egxknos7ghe4M5Z2rK7DvphcaxQBdxyppu5N2tdCVc/3oPO2ZtBNjDjtksqywC12wPtIYgHSgxrzvLEfbh5skw==", "license": "MIT", "workspaces": [ "parser", @@ -22115,9 +22115,9 @@ } }, "node_modules/electron": { - "version": "29.4.6", - "resolved": "https://registry.npmjs.org/electron/-/electron-29.4.6.tgz", - "integrity": "sha512-fz8ndj8cmmf441t4Yh2FDP3Rn0JhLkVGvtUf2YVMbJ5SdJPlc0JWll9jYkhh60jDKVVCr/tBAmfxqRnXMWJpzg==", + "version": "32.2.3", + "resolved": "https://registry.npmjs.org/electron/-/electron-32.2.3.tgz", + "integrity": "sha512-ClTJrFuwBdZpDNEnVZSV1gTIYSq7c/TYoUv9AmOypL43/xtbfxXkz2vE67ehVoamFobWsIU2by087R5Av8cxJg==", "dev": true, "hasInstallScript": true, "license": "MIT", diff --git a/package.json b/package.json index a7f306235c0f..8d6612308505 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "9.0.59-0", + "version": "9.0.61-0", "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.", @@ -68,7 +68,7 @@ }, "dependencies": { "@dotlottie/react-player": "^1.6.3", - "@expensify/react-native-live-markdown": "0.1.180", + "@expensify/react-native-live-markdown": "0.1.183", "@expo/metro-runtime": "~3.2.3", "@firebase/app": "^0.10.10", "@firebase/performance": "^0.6.8", @@ -272,7 +272,7 @@ "csv-writer": "^1.6.0", "diff-so-fancy": "^1.3.0", "dotenv": "^16.0.3", - "electron": "^29.4.6", + "electron": "^32.2.3", "electron-builder": "25.0.0", "eslint": "^8.57.0", "eslint-config-airbnb-typescript": "^18.0.0", diff --git a/patches/react-native+0.75.2+020+keyboard-avoiding-view.patch b/patches/react-native+0.75.2+020+keyboard-avoiding-view.patch new file mode 100644 index 000000000000..2ee8aa1fd0de --- /dev/null +++ b/patches/react-native+0.75.2+020+keyboard-avoiding-view.patch @@ -0,0 +1,18 @@ +diff --git a/node_modules/react-native/Libraries/Components/Keyboard/KeyboardAvoidingView.js b/node_modules/react-native/Libraries/Components/Keyboard/KeyboardAvoidingView.js +index e26d677..597be5a 100644 +--- a/node_modules/react-native/Libraries/Components/Keyboard/KeyboardAvoidingView.js ++++ b/node_modules/react-native/Libraries/Components/Keyboard/KeyboardAvoidingView.js +@@ -175,6 +175,13 @@ class KeyboardAvoidingView extends React.Component { + } + + componentDidMount(): void { ++ // Fix KeyboardAvoidingView not aware of the keyboard closing after it is unmounted. ++ // Remove this patch after the upstream fix https://github.com/facebook/react-native/commit/08bd8ac47da60121225e7b281bbf566e2c5a291e is released. ++ if (!Keyboard.isVisible()) { ++ this._keyboardEvent = null; ++ this._setBottom(0); ++ } ++ + if (Platform.OS === 'ios') { + this._subscriptions = [ + Keyboard.addListener('keyboardWillChangeFrame', this._onKeyboardChange), diff --git a/src/CONST.ts b/src/CONST.ts index 2ce1d85a0d7e..aecc2c7d4829 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -297,6 +297,7 @@ const CONST = { DEFAULT_TABLE_NAME: 'keyvaluepairs', DEFAULT_ONYX_DUMP_FILE_NAME: 'onyx-state.txt', DEFAULT_POLICY_ROOM_CHAT_TYPES: [chatTypes.POLICY_ADMINS, chatTypes.POLICY_ANNOUNCE, chatTypes.DOMAIN_ALL], + DEFAULT_IMAGE_FILE_NAME: 'image', DISABLED_MAX_EXPENSE_VALUE: 10000000000, POLICY_BILLABLE_MODES: { BILLABLE: 'billable', @@ -1253,17 +1254,13 @@ const CONST = { }, TIMING: { CALCULATE_MOST_RECENT_LAST_MODIFIED_ACTION: 'calc_most_recent_last_modified_action', - SEARCH_ROUTER_RENDER: 'search_router_render', - CHAT_RENDER: 'chat_render', + OPEN_SEARCH: 'open_search', OPEN_REPORT: 'open_report', - HOMEPAGE_INITIAL_RENDER: 'homepage_initial_render', - REPORT_INITIAL_RENDER: 'report_initial_render', - SWITCH_REPORT: 'switch_report', OPEN_REPORT_FROM_PREVIEW: 'open_report_from_preview', OPEN_REPORT_THREAD: 'open_report_thread', SIDEBAR_LOADED: 'sidebar_loaded', LOAD_SEARCH_OPTIONS: 'load_search_options', - MESSAGE_SENT: 'message_sent', + SEND_MESSAGE: 'send_message', COLD: 'cold', WARM: 'warm', REPORT_ACTION_ITEM_LAYOUT_DEBOUNCE_TIME: 1500, @@ -1859,7 +1856,6 @@ const CONST = { 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,'], }, @@ -4548,9 +4544,6 @@ const CONST = { }, INDENTS: ' ', PARENT_CHILD_SEPARATOR: ': ', - CATEGORY_LIST_THRESHOLD: 8, - TAG_LIST_THRESHOLD: 8, - TAX_RATES_LIST_THRESHOLD: 8, COLON: ':', MAPBOX: { PADDING: 32, @@ -4634,11 +4627,6 @@ const CONST = { */ MAX_SELECTION_LIST_PAGE_LENGTH: 500, - /** - * We only include the members search bar when we have 8 or more members - */ - SHOULD_SHOW_MEMBERS_SEARCH_INPUT_BREAKPOINT: 8, - /** * Bank account names */ @@ -4812,7 +4800,6 @@ const CONST = { WORKSPACE_SWITCHER: { NAME: 'Expensify', SUBSCRIPT_ICON_SIZE: 8, - MINIMUM_WORKSPACES_TO_SHOW_SEARCH: 8, }, WELCOME_VIDEO_URL: `${CLOUDFRONT_URL}/videos/intro-1280.mp4`, @@ -5841,7 +5828,6 @@ const CONST = { MAX_TAX_RATE_INTEGER_PLACES: 4, MAX_TAX_RATE_DECIMAL_PLACES: 4, - MIN_TAX_RATE_DECIMAL_PLACES: 2, DOWNLOADS_PATH: '/Downloads', DOWNLOADS_TIMEOUT: 5000, diff --git a/src/Expensify.tsx b/src/Expensify.tsx index e07b03a6d405..1d0100add00f 100644 --- a/src/Expensify.tsx +++ b/src/Expensify.tsx @@ -30,7 +30,6 @@ import NavigationRoot from './libs/Navigation/NavigationRoot'; import NetworkConnection from './libs/NetworkConnection'; import PushNotification from './libs/Notification/PushNotification'; import './libs/Notification/PushNotification/subscribePushNotification'; -import Performance from './libs/Performance'; import setCrashlyticsUserId from './libs/setCrashlyticsUserId'; import StartupTimer from './libs/StartupTimer'; // This lib needs to be imported, but it has nothing to export since all it contains is an Onyx connection @@ -138,7 +137,6 @@ function Expensify() { const onSplashHide = useCallback(() => { setSplashScreenState(CONST.BOOT_SPLASH_STATE.HIDDEN); - Performance.markEnd(CONST.TIMING.SIDEBAR_LOADED); }, [setSplashScreenState]); useLayoutEffect(() => { diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 67b7229fe7e9..9f9b4070ec1d 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -310,11 +310,13 @@ const ROUTES = { }, ATTACHMENTS: { route: 'attachment', - getRoute: (reportID: string, type: ValueOf, url: string, accountID?: number, isAuthTokenRequired?: boolean) => { + getRoute: (reportID: string, type: ValueOf, url: string, accountID?: number, isAuthTokenRequired?: boolean, fileName?: string) => { const reportParam = reportID ? `&reportID=${reportID}` : ''; const accountParam = accountID ? `&accountID=${accountID}` : ''; const authTokenParam = isAuthTokenRequired ? '&isAuthTokenRequired=true' : ''; - return `attachment?source=${encodeURIComponent(url)}&type=${type}${reportParam}${accountParam}${authTokenParam}` as const; + const fileNameParam = fileName ? `&fileName=${fileName}` : ''; + + return `attachment?source=${encodeURIComponent(url)}&type=${type}${reportParam}${accountParam}${authTokenParam}${fileNameParam}` as const; }, }, REPORT_PARTICIPANTS: { diff --git a/src/components/Attachments/AttachmentCarousel/CarouselItem.tsx b/src/components/Attachments/AttachmentCarousel/CarouselItem.tsx index 4de43a763231..5800e92cc4f4 100644 --- a/src/components/Attachments/AttachmentCarousel/CarouselItem.tsx +++ b/src/components/Attachments/AttachmentCarousel/CarouselItem.tsx @@ -4,6 +4,7 @@ import {View} from 'react-native'; import AttachmentView from '@components/Attachments/AttachmentView'; import type {Attachment} from '@components/Attachments/types'; import Button from '@components/Button'; +import * as Expensicons from '@components/Icon/Expensicons'; import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback'; import SafeAreaConsumer from '@components/SafeAreaConsumer'; import Text from '@components/Text'; @@ -83,6 +84,7 @@ function CarouselItem({item, onPress, isFocused, isModalHovered}: CarouselItemPr isHovered={isModalHovered} isFocused={isFocused} duration={item.duration} + fallbackSource={Expensicons.AttachmentNotFound} /> diff --git a/src/components/Attachments/AttachmentView/index.tsx b/src/components/Attachments/AttachmentView/index.tsx index 0af1a86992e7..1281c017308d 100644 --- a/src/components/Attachments/AttachmentView/index.tsx +++ b/src/components/Attachments/AttachmentView/index.tsx @@ -10,6 +10,7 @@ import EReceipt from '@components/EReceipt'; import Icon from '@components/Icon'; import * as Expensicons from '@components/Icon/Expensicons'; import ScrollView from '@components/ScrollView'; +import Text from '@components/Text'; import {usePlaybackContext} from '@components/VideoPlayerContexts/PlaybackContext'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; @@ -127,7 +128,7 @@ function AttachmentView({ const [imageError, setImageError] = useState(false); - useNetwork({onReconnect: () => setImageError(false)}); + const {isOffline} = useNetwork({onReconnect: () => setImageError(false)}); useEffect(() => { FileUtils.getFileResolution(file).then((resolution) => { @@ -226,15 +227,20 @@ function AttachmentView({ if (isFileImage) { if (imageError && (typeof fallbackSource === 'number' || typeof fallbackSource === 'function')) { return ( - + + + + {translate('attachmentView.attachmentNotFound')} + + ); } + let imageSource = imageError && fallbackSource ? (fallbackSource as string) : (source as string); if (isHighResolution) { @@ -268,6 +274,9 @@ function AttachmentView({ isImage={isFileImage} onPress={onPress} onError={() => { + if (isOffline) { + return; + } setImageError(true); }} /> diff --git a/src/components/CategoryPicker.tsx b/src/components/CategoryPicker.tsx index 33d97c6909f5..0c855507371a 100644 --- a/src/components/CategoryPicker.tsx +++ b/src/components/CategoryPicker.tsx @@ -56,7 +56,7 @@ function CategoryPicker({selectedCategory, policyID, onSubmit}: CategoryPickerPr const categoryData = categoryOptions?.at(0)?.data ?? []; const header = OptionsListUtils.getHeaderMessageForNonUserList(categoryData.length > 0, debouncedSearchValue); const categoriesCount = OptionsListUtils.getEnabledCategoriesCount(categories); - const isCategoriesCountBelowThreshold = categoriesCount < CONST.CATEGORY_LIST_THRESHOLD; + const isCategoriesCountBelowThreshold = categoriesCount < CONST.STANDARD_LIST_ITEM_LIMIT; const showInput = !isCategoriesCountBelowThreshold; return [categoryOptions, header, showInput]; diff --git a/src/components/FocusModeNotification.tsx b/src/components/FocusModeNotification.tsx index 7b3f567d256b..fe63fb4b487b 100644 --- a/src/components/FocusModeNotification.tsx +++ b/src/components/FocusModeNotification.tsx @@ -24,6 +24,8 @@ function FocusModeNotification() { confirmText={translate('common.buttonConfirm')} onConfirm={User.clearFocusModeNotification} shouldShowCancelButton={false} + onBackdropPress={User.clearFocusModeNotification} + onCancel={User.clearFocusModeNotification} prompt={ {translate('focusModeUpdateModal.prompt')} diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.tsx index 17fbe1656020..f53e490dd0f9 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.tsx @@ -1,4 +1,4 @@ -import React, {memo, useState} from 'react'; +import React, {memo} from 'react'; import {useOnyx} from 'react-native-onyx'; import type {OnyxEntry} from 'react-native-onyx'; import type {CustomRendererProps, TBlock} from 'react-native-render-html'; @@ -67,9 +67,14 @@ function ImageRenderer({tnode}: ImageRendererProps) { const fileType = FileUtils.getFileType(attachmentSourceAttribute); const fallbackIcon = fileType === CONST.ATTACHMENT_FILE_TYPE.FILE ? Expensicons.Document : Expensicons.GalleryNotFound; - const [hasLoadFailed, setHasLoadFailed] = useState(true); const theme = useTheme(); + let fileName = htmlAttribs[CONST.ATTACHMENT_ORIGINAL_FILENAME_ATTRIBUTE] || FileUtils.getFileName(`${isAttachmentOrReceipt ? attachmentSourceAttribute : htmlAttribs.src}`); + const fileInfo = FileUtils.splitExtensionFromFileName(fileName); + if (!fileInfo.fileExtension) { + fileName = `${fileInfo?.fileName || CONST.DEFAULT_IMAGE_FILE_NAME}.jpg`; + } + const thumbnailImageComponent = ( setHasLoadFailed(true)} - onMeasure={() => setHasLoadFailed(false)} fallbackIconBackground={theme.highlightBG} fallbackIconColor={theme.border} /> @@ -101,7 +104,7 @@ function ImageRenderer({tnode}: ImageRendererProps) { return; } - const route = ROUTES.ATTACHMENTS?.getRoute(reportID ?? '-1', type, source, accountID, isAttachmentOrReceipt); + const route = ROUTES.ATTACHMENTS?.getRoute(reportID ?? '-1', type, source, accountID, isAttachmentOrReceipt, fileName); Navigation.navigate(route); }} onLongPress={(event) => { @@ -113,7 +116,6 @@ function ImageRenderer({tnode}: ImageRendererProps) { shouldUseHapticsOnLongPress accessibilityRole={CONST.ROLE.BUTTON} accessibilityLabel={translate('accessibilityHints.viewAttachment')} - disabled={hasLoadFailed} > {thumbnailImageComponent} diff --git a/src/components/Icon/Expensicons.ts b/src/components/Icon/Expensicons.ts index fa531ce34adf..bd4bb64da050 100644 --- a/src/components/Icon/Expensicons.ts +++ b/src/components/Icon/Expensicons.ts @@ -8,6 +8,7 @@ import ArrowRight from '@assets/images/arrow-right.svg'; import ArrowUpLong from '@assets/images/arrow-up-long.svg'; import UpArrow from '@assets/images/arrow-up.svg'; import ArrowsUpDown from '@assets/images/arrows-updown.svg'; +import AttachmentNotFound from '@assets/images/attachment-not-found.svg'; import AdminRoomAvatar from '@assets/images/avatars/admin-room.svg'; import AnnounceRoomAvatar from '@assets/images/avatars/announce-room.svg'; import ConciergeAvatar from '@assets/images/avatars/concierge-avatar.svg'; @@ -217,6 +218,7 @@ export { ArrowsUpDown, ArrowUpLong, ArrowDownLong, + AttachmentNotFound, Wrench, BackArrow, Bank, diff --git a/src/components/ImageView/index.tsx b/src/components/ImageView/index.tsx index 266ed2eed16a..0bce2fd38432 100644 --- a/src/components/ImageView/index.tsx +++ b/src/components/ImageView/index.tsx @@ -196,8 +196,12 @@ function ImageView({isAuthTokenRequired = false, url, fileName, onError}: ImageV document.removeEventListener('mouseup', trackPointerPosition); }; }, [canUseTouchScreen, trackMovement, trackPointerPosition]); - - const isLocalFile = FileUtils.isLocalFile(url); + // isLocalToUserDeviceFile means the file is located on the user device, + // not loaded on the server yet (the user is offline when loading this file in fact) + let isLocalToUserDeviceFile = FileUtils.isLocalFile(url); + if (isLocalToUserDeviceFile && typeof url === 'string' && url.startsWith('/chat-attachments')) { + isLocalToUserDeviceFile = false; + } if (canUseTouchScreen) { return ( @@ -238,8 +242,8 @@ function ImageView({isAuthTokenRequired = false, url, fileName, onError}: ImageV /> - {isLoading && (!isOffline || isLocalFile) && } - {isLoading && !isLocalFile && } + {isLoading && (!isOffline || isLocalToUserDeviceFile) && } + {isLoading && !isLocalToUserDeviceFile && } ); } diff --git a/src/components/LHNOptionsList/OptionRowLHN.tsx b/src/components/LHNOptionsList/OptionRowLHN.tsx index 94116181bccb..3e3f4d1b8e5d 100644 --- a/src/components/LHNOptionsList/OptionRowLHN.tsx +++ b/src/components/LHNOptionsList/OptionRowLHN.tsx @@ -30,6 +30,7 @@ import * as ReportUtils from '@libs/ReportUtils'; import * as ReportActionContextMenu from '@pages/home/report/ContextMenu/ReportActionContextMenu'; import FreeTrial from '@pages/settings/Subscription/FreeTrial'; import variables from '@styles/variables'; +import Timing from '@userActions/Timing'; import * as User from '@userActions/User'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -193,6 +194,7 @@ function OptionRowLHN({reportID, isFocused = false, onSelectRow = () => {}, opti ref={popoverAnchor} onPress={(event) => { Performance.markStart(CONST.TIMING.OPEN_REPORT); + Timing.start(CONST.TIMING.OPEN_REPORT); event?.preventDefault(); // Enable Composer to focus on clicking the same chat after opening the context menu. diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx index ee87f8f12c7d..274cb5a36856 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -200,8 +200,9 @@ function MoneyRequestView({report, shouldShowAnimatedBackground, readonly = fals let amountDescription = `${translate('iou.amount')}`; const hasRoute = TransactionUtils.hasRoute(transactionBackup ?? transaction, isDistanceRequest); - const {unit, rate, currency} = DistanceRequestUtils.getRate({transaction, policy}); + const {unit, rate} = DistanceRequestUtils.getRate({transaction, policy}); const distance = TransactionUtils.getDistanceInMeters(transactionBackup ?? transaction, unit); + const currency = transactionCurrency ?? CONST.CURRENCY.USD; const rateToDisplay = DistanceRequestUtils.getRateForDisplay(unit, rate, currency, translate, toLocaleDigit, isOffline); const distanceToDisplay = DistanceRequestUtils.getDistanceForDisplay(hasRoute, distance, unit, rate, translate); let merchantTitle = isEmptyMerchant ? '' : transactionMerchant; @@ -447,7 +448,7 @@ function MoneyRequestView({report, shouldShowAnimatedBackground, readonly = fals )} {(hasReceipt || !!errors) && ( { diff --git a/src/components/ReportActionItem/ReportPreview.tsx b/src/components/ReportActionItem/ReportPreview.tsx index 9067f1abb11a..d476d1198808 100644 --- a/src/components/ReportActionItem/ReportPreview.tsx +++ b/src/components/ReportActionItem/ReportPreview.tsx @@ -27,6 +27,7 @@ import * as CurrencyUtils from '@libs/CurrencyUtils'; import * as DeviceCapabilities from '@libs/DeviceCapabilities'; import HapticFeedback from '@libs/HapticFeedback'; import Navigation from '@libs/Navigation/Navigation'; +import Performance from '@libs/Performance'; import * as PolicyUtils from '@libs/PolicyUtils'; import * as ReceiptUtils from '@libs/ReceiptUtils'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; @@ -456,6 +457,7 @@ function ReportPreview({ { + Performance.markStart(CONST.TIMING.OPEN_REPORT_FROM_PREVIEW); Timing.start(CONST.TIMING.OPEN_REPORT_FROM_PREVIEW); Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(iouReportID)); }} diff --git a/src/components/Search/SearchRouter/SearchButton.tsx b/src/components/Search/SearchRouter/SearchButton.tsx index 76eacd8b991d..90699e951998 100644 --- a/src/components/Search/SearchRouter/SearchButton.tsx +++ b/src/components/Search/SearchRouter/SearchButton.tsx @@ -30,8 +30,8 @@ function SearchButton({style}: SearchButtonProps) { accessibilityLabel={translate('common.search')} style={[styles.flexRow, styles.touchableButtonImage, style]} onPress={Session.checkIfActionIsAllowed(() => { - Timing.start(CONST.TIMING.SEARCH_ROUTER_RENDER); - Performance.markStart(CONST.TIMING.SEARCH_ROUTER_RENDER); + Timing.start(CONST.TIMING.OPEN_SEARCH); + Performance.markStart(CONST.TIMING.OPEN_SEARCH); openSearchRouter(); })} diff --git a/src/components/Search/SearchRouter/SearchRouterList.tsx b/src/components/Search/SearchRouter/SearchRouterList.tsx index cc854ff926c3..45e30a6bad6d 100644 --- a/src/components/Search/SearchRouter/SearchRouterList.tsx +++ b/src/components/Search/SearchRouter/SearchRouterList.tsx @@ -69,8 +69,8 @@ type SearchRouterListProps = { }; const setPerformanceTimersEnd = () => { - Timing.end(CONST.TIMING.SEARCH_ROUTER_RENDER); - Performance.markEnd(CONST.TIMING.SEARCH_ROUTER_RENDER); + Timing.end(CONST.TIMING.OPEN_SEARCH); + Performance.markEnd(CONST.TIMING.OPEN_SEARCH); }; function getContextualSearchQuery(reportName: string) { diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index 7674500a8f1d..3a1f88aad2a9 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -407,7 +407,7 @@ function Search({queryJSON, onSearchListScroll, isSearchScreenFocused, contentCo }; const shouldShowYear = SearchUIUtils.shouldShowYear(searchResults?.data); - const shouldShowSorting = Array.isArray(status) ? status.some((s) => sortableSearchStatuses.includes(s)) : sortableSearchStatuses.includes(status); + const shouldShowSorting = !Array.isArray(status) && sortableSearchStatuses.includes(status); return ( diff --git a/src/components/TagPicker/index.tsx b/src/components/TagPicker/index.tsx index 9d3a70d4d50c..a64c4c276606 100644 --- a/src/components/TagPicker/index.tsx +++ b/src/components/TagPicker/index.tsx @@ -52,7 +52,7 @@ function TagPicker({selectedTag, tagListName, policyID, tagListIndex, shouldShow const policyRecentlyUsedTagsList = useMemo(() => policyRecentlyUsedTags?.[tagListName] ?? [], [policyRecentlyUsedTags, tagListName]); const policyTagList = PolicyUtils.getTagList(policyTags, tagListIndex); const policyTagsCount = PolicyUtils.getCountOfEnabledTagsOfList(policyTagList.tags); - const isTagsCountBelowThreshold = policyTagsCount < CONST.TAG_LIST_THRESHOLD; + const isTagsCountBelowThreshold = policyTagsCount < CONST.STANDARD_LIST_ITEM_LIMIT; const shouldShowTextInput = !isTagsCountBelowThreshold; diff --git a/src/components/TaxPicker.tsx b/src/components/TaxPicker.tsx index 78ccb1425e21..a91965811d81 100644 --- a/src/components/TaxPicker.tsx +++ b/src/components/TaxPicker.tsx @@ -1,33 +1,21 @@ import React, {useCallback, useMemo, useState} from 'react'; -import {withOnyx} from 'react-native-onyx'; -import type {OnyxEntry} from 'react-native-onyx'; +import {useOnyx} from 'react-native-onyx'; import type {EdgeInsets} from 'react-native-safe-area-context'; import type {ValueOf} from 'type-fest'; import useLocalize from '@hooks/useLocalize'; import useStyleUtils from '@hooks/useStyleUtils'; import * as IOUUtils from '@libs/IOUUtils'; import * as OptionsListUtils from '@libs/OptionsListUtils'; +import * as PolicyUtils from '@libs/PolicyUtils'; import * as TransactionUtils from '@libs/TransactionUtils'; import CONST from '@src/CONST'; import type {IOUAction} from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {Policy, Transaction} from '@src/types/onyx'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import SelectionList from './SelectionList'; import RadioListItem from './SelectionList/RadioListItem'; -type TaxPickerOnyxProps = { - /** The policy which the user has access to and which the report is tied to */ - policy: OnyxEntry; - - /** All the data for the transaction */ - transaction: OnyxEntry; - - /** The draft transaction that holds data to be persisted on the current split transaction */ - splitDraftTransaction: OnyxEntry; -}; - -type TaxPickerProps = TaxPickerOnyxProps & { +type TaxPickerProps = { /** The selected tax rate of an expense */ selectedTaxRate?: string; @@ -58,10 +46,16 @@ type TaxPickerProps = TaxPickerOnyxProps & { onDismiss: () => void; }; -function TaxPicker({selectedTaxRate = '', policy, transaction, insets, onSubmit, action, splitDraftTransaction, iouType, onDismiss}: TaxPickerProps) { +function TaxPicker({selectedTaxRate = '', policyID, transactionID, insets, onSubmit, action, iouType, onDismiss}: TaxPickerProps) { const StyleUtils = useStyleUtils(); const {translate} = useLocalize(); const [searchValue, setSearchValue] = useState(''); + const policy = PolicyUtils.getPolicy(policyID); + const [draftTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}` as `${typeof ONYXKEYS.COLLECTION.TRANSACTION}${string}`); + const [defaultTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`); + const [splitDraftTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transactionID}`); + + const transaction = IOUUtils.shouldUseTransactionDraft(action) ? draftTransaction : defaultTransaction; const isEditing = action === CONST.IOU.ACTION.EDIT; const isEditingSplitBill = isEditing && iouType === CONST.IOU.TYPE.SPLIT; @@ -69,7 +63,7 @@ function TaxPicker({selectedTaxRate = '', policy, transaction, insets, onSubmit, const taxRates = policy?.taxRates; const taxRatesCount = TransactionUtils.getEnabledTaxRateCount(taxRates?.taxes ?? {}); - const isTaxRatesCountBelowThreshold = taxRatesCount < CONST.TAX_RATES_LIST_THRESHOLD; + const isTaxRatesCountBelowThreshold = taxRatesCount < CONST.STANDARD_LIST_ITEM_LIMIT; const shouldShowTextInput = !isTaxRatesCountBelowThreshold; @@ -125,19 +119,4 @@ function TaxPicker({selectedTaxRate = '', policy, transaction, insets, onSubmit, TaxPicker.displayName = 'TaxPicker'; -export default withOnyx({ - policy: { - key: ({policyID}) => `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, - }, - transaction: { - key: ({transactionID, action}) => { - if (IOUUtils.shouldUseTransactionDraft(action)) { - return `${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}` as `${typeof ONYXKEYS.COLLECTION.TRANSACTION}${string}`; - } - return `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`; - }, - }, - splitDraftTransaction: { - key: ({transactionID}) => `${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transactionID}`, - }, -})(TaxPicker); +export default TaxPicker; diff --git a/src/languages/en.ts b/src/languages/en.ts index 8c8739a748fb..ba89ab53b8f3 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -743,6 +743,10 @@ const translations = { listOfChats: 'List of chats', saveTheWorld: 'Save the world', tooltip: 'Get started here!', + redirectToExpensifyClassicModal: { + title: 'Coming soon', + description: "We're fine-tuning a few more bits and pieces of New Expensify to accommodate your specific setup. In the meantime, head over to Expensify Classic.", + }, }, allSettingsScreen: { subscription: 'Subscription', @@ -1967,6 +1971,7 @@ const translations = { afterLinkText: 'to view it.', formLabel: 'View PDF', }, + attachmentNotFound: 'Attachment not found', }, messages: { errorMessageInvalidPhone: `Please enter a valid phone number without brackets or dashes. If you're outside the US, please include your country code (e.g. ${CONST.EXAMPLE_PHONE_NUMBER}).`, @@ -3467,7 +3472,7 @@ const translations = { }, rules: { title: 'Rules', - subtitle: 'Configure when receipts are required, flag high spend, and more.', + subtitle: 'Require receipts, flag high spend, and more.', }, }, reportFields: { diff --git a/src/languages/es.ts b/src/languages/es.ts index 1e5ddd8b7ee6..d6fbb23649b6 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -738,6 +738,10 @@ const translations = { listOfChats: 'lista de chats', saveTheWorld: 'Salvar el mundo', tooltip: '¡Comienza aquí!', + redirectToExpensifyClassicModal: { + title: 'Próximamente', + description: 'Estamos ajustando algunos detalles de New Expensify para adaptarla a tu configuración específica. Mientras tanto, dirígete a Expensify Classic.', + }, }, allSettingsScreen: { subscription: 'Suscripcion', @@ -1988,6 +1992,7 @@ const translations = { afterLinkText: 'para verlo.', formLabel: 'Ver PDF', }, + attachmentNotFound: 'Archivo adjunto no encontrado', }, messages: { errorMessageInvalidPhone: `Por favor, introduce un número de teléfono válido sin paréntesis o guiones. Si reside fuera de Estados Unidos, por favor incluye el prefijo internacional (p. ej. ${CONST.EXAMPLE_PHONE_NUMBER}).`, @@ -3511,7 +3516,7 @@ const translations = { }, rules: { title: 'Reglas', - subtitle: 'Configura cuándo se exigen los recibos, marca los gastos elevados y mucho más.', + subtitle: 'Solicita recibos, resalta gastos de alto importe y mucho más.', }, }, reportFields: { diff --git a/src/libs/API/parameters/EditMoneyRequestParams.ts b/src/libs/API/parameters/EditMoneyRequestParams.ts deleted file mode 100644 index 6d320510e267..000000000000 --- a/src/libs/API/parameters/EditMoneyRequestParams.ts +++ /dev/null @@ -1,14 +0,0 @@ -type EditMoneyRequestParams = { - transactionID: string; - reportActionID: string; - created?: string; - amount?: number; - currency?: string; - comment?: string; - merchant?: string; - category?: string; - billable?: boolean; - tag?: string; -}; - -export default EditMoneyRequestParams; diff --git a/src/libs/API/parameters/SetCompanyCardExportAccountParams.ts b/src/libs/API/parameters/SetCompanyCardExportAccountParams.ts index 861345ff9c55..09f7b1340a36 100644 --- a/src/libs/API/parameters/SetCompanyCardExportAccountParams.ts +++ b/src/libs/API/parameters/SetCompanyCardExportAccountParams.ts @@ -1,7 +1,7 @@ type SetCompanyCardExportAccountParams = { authToken?: string | null; cardID: number; - exportAccountDetails: Record; + exportAccountDetails: string; }; export default SetCompanyCardExportAccountParams; diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts index d7f181e0e92b..681114fd3b08 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -158,7 +158,6 @@ export type {default as StartSplitBillParams} from './StartSplitBillParams'; export type {default as SendMoneyParams} from './SendMoneyParams'; export type {default as ApproveMoneyRequestParams} from './ApproveMoneyRequestParams'; export type {default as UnapproveExpenseReportParams} from './UnapproveExpenseReportParams'; -export type {default as EditMoneyRequestParams} from './EditMoneyRequestParams'; export type {default as ReplaceReceiptParams} from './ReplaceReceiptParams'; export type {default as SubmitReportParams} from './SubmitReportParams'; export type {default as DetachReceiptParams} from './DetachReceiptParams'; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index 8d0446708627..bd8a58555617 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -192,7 +192,6 @@ const WRITE_COMMANDS = { HOLD_MONEY_REQUEST: 'HoldRequest', UPDATE_BILLING_CARD_CURRENCY: 'UpdateBillingCardCurrency', UNHOLD_MONEY_REQUEST: 'UnHoldRequest', - UPDATE_DISTANCE_REQUEST: 'UpdateDistanceRequest', REQUEST_MONEY: 'RequestMoney', SPLIT_BILL: 'SplitBill', SPLIT_BILL_AND_OPEN_REPORT: 'SplitBillAndOpenReport', @@ -203,7 +202,6 @@ const WRITE_COMMANDS = { SEND_MONEY_WITH_WALLET: 'SendMoneyWithWallet', APPROVE_MONEY_REQUEST: 'ApproveMoneyRequest', UNAPPROVE_EXPENSE_REPORT: 'UnapproveExpenseReport', - EDIT_MONEY_REQUEST: 'EditMoneyRequest', REPLACE_RECEIPT: 'ReplaceReceipt', SUBMIT_REPORT: 'SubmitReport', DETACH_RECEIPT: 'DetachReceipt', @@ -617,7 +615,6 @@ type WriteCommandParameters = { [WRITE_COMMANDS.UPDATE_MONEY_REQUEST_DESCRIPTION]: Parameters.UpdateMoneyRequestParams; [WRITE_COMMANDS.HOLD_MONEY_REQUEST]: Parameters.HoldMoneyRequestParams; [WRITE_COMMANDS.UNHOLD_MONEY_REQUEST]: Parameters.UnHoldMoneyRequestParams; - [WRITE_COMMANDS.UPDATE_DISTANCE_REQUEST]: Parameters.UpdateMoneyRequestParams; [WRITE_COMMANDS.UPDATE_MONEY_REQUEST_AMOUNT_AND_CURRENCY]: Parameters.UpdateMoneyRequestParams; [WRITE_COMMANDS.REQUEST_MONEY]: Parameters.RequestMoneyParams; [WRITE_COMMANDS.SPLIT_BILL]: Parameters.SplitBillParams; @@ -629,7 +626,6 @@ type WriteCommandParameters = { [WRITE_COMMANDS.SEND_MONEY_WITH_WALLET]: Parameters.SendMoneyParams; [WRITE_COMMANDS.APPROVE_MONEY_REQUEST]: Parameters.ApproveMoneyRequestParams; [WRITE_COMMANDS.UNAPPROVE_EXPENSE_REPORT]: Parameters.UnapproveExpenseReportParams; - [WRITE_COMMANDS.EDIT_MONEY_REQUEST]: Parameters.EditMoneyRequestParams; [WRITE_COMMANDS.REPLACE_RECEIPT]: Parameters.ReplaceReceiptParams; [WRITE_COMMANDS.SUBMIT_REPORT]: Parameters.SubmitReportParams; [WRITE_COMMANDS.DETACH_RECEIPT]: Parameters.DetachReceiptParams; diff --git a/src/libs/CurrencyUtils.ts b/src/libs/CurrencyUtils.ts index b701a32a7c98..f9ac681cb468 100644 --- a/src/libs/CurrencyUtils.ts +++ b/src/libs/CurrencyUtils.ts @@ -164,8 +164,7 @@ function convertAmountToDisplayString(amount = 0, currency: string = CONST.CURRE return NumberFormatUtils.format(BaseLocaleListener.getPreferredLocale(), convertedAmount, { style: 'currency', currency, - minimumFractionDigits: CONST.MIN_TAX_RATE_DECIMAL_PLACES, - maximumFractionDigits: CONST.MAX_TAX_RATE_DECIMAL_PLACES, + minimumFractionDigits: CONST.MAX_TAX_RATE_DECIMAL_PLACES, }); } diff --git a/src/libs/DistanceRequestUtils.ts b/src/libs/DistanceRequestUtils.ts index 86e9c23af97b..3b8e26c9cd33 100644 --- a/src/libs/DistanceRequestUtils.ts +++ b/src/libs/DistanceRequestUtils.ts @@ -364,6 +364,14 @@ function getUpdatedDistanceUnit({transaction, policy, policyDraft}: {transaction return getRate({transaction, policy, policyDraft, useTransactionDistanceUnit: false}).unit; } +/** + * Get the mileage rate by its ID in the form it's configured for the policy. + * If not found, return undefined. + */ +function getRateByCustomUnitRateID({customUnitRateID, policy}: {customUnitRateID: string; policy: OnyxEntry}): MileageRate | undefined { + return getMileageRates(policy, true, customUnitRateID)[customUnitRateID]; +} + export default { getDefaultMileageRate, getDistanceMerchant, @@ -378,6 +386,7 @@ export default { getDistanceUnit, getUpdatedDistanceUnit, getRate, + getRateByCustomUnitRateID, }; export type {MileageRate}; diff --git a/src/libs/E2E/tests/chatOpeningTest.e2e.ts b/src/libs/E2E/tests/chatOpeningTest.e2e.ts index cf0c4889aa69..62a01e43755d 100644 --- a/src/libs/E2E/tests/chatOpeningTest.e2e.ts +++ b/src/libs/E2E/tests/chatOpeningTest.e2e.ts @@ -27,10 +27,9 @@ const test = (config: NativeConfig) => { console.debug('[E2E] Logged in, getting chat opening metrics and submitting them…'); - const [renderChatPromise, renderChatResolve] = getPromiseWithResolve(); const [chatTTIPromise, chatTTIResolve] = getPromiseWithResolve(); - Promise.all([renderChatPromise, chatTTIPromise]).then(() => { + chatTTIPromise.then(() => { console.debug(`[E2E] Submitting!`); E2EClient.submitTestDone(); @@ -46,22 +45,6 @@ const test = (config: NativeConfig) => { console.debug(`[E2E] Entry: ${JSON.stringify(entry)}`); - if (entry.name === CONST.TIMING.CHAT_RENDER) { - E2EClient.submitTestResults({ - branch: Config.E2E_BRANCH, - name: `${name} Chat opening`, - metric: entry.duration, - unit: 'ms', - }) - .then(() => { - console.debug('[E2E] Done with chat opening, exiting…'); - renderChatResolve(); - }) - .catch((err) => { - console.debug('[E2E] Error while submitting test results:', err); - }); - } - if (entry.name === CONST.TIMING.OPEN_REPORT) { E2EClient.submitTestResults({ branch: Config.E2E_BRANCH, diff --git a/src/libs/E2E/tests/linkingTest.e2e.ts b/src/libs/E2E/tests/linkingTest.e2e.ts index 18ba438c2ca6..2a85a5dabe6c 100644 --- a/src/libs/E2E/tests/linkingTest.e2e.ts +++ b/src/libs/E2E/tests/linkingTest.e2e.ts @@ -1,7 +1,6 @@ import {DeviceEventEmitter} from 'react-native'; -import type {NativeConfig} from 'react-native-config'; import Config from 'react-native-config'; -import Timing from '@libs/actions/Timing'; +import type {NativeConfig} from 'react-native-config'; import E2ELogin from '@libs/E2E/actions/e2eLogin'; import waitForAppLoaded from '@libs/E2E/actions/waitForAppLoaded'; import E2EClient from '@libs/E2E/client'; @@ -32,9 +31,9 @@ const test = (config: NativeConfig) => { } const [appearMessagePromise, appearMessageResolve] = getPromiseWithResolve(); - const [switchReportPromise, switchReportResolve] = getPromiseWithResolve(); + const [openReportPromise, openReportResolve] = getPromiseWithResolve(); - Promise.all([appearMessagePromise, switchReportPromise]) + Promise.all([appearMessagePromise, openReportPromise]) .then(() => { console.debug('[E2E] Test completed successfully, exiting…'); E2EClient.submitTestDone(); @@ -57,21 +56,15 @@ const test = (config: NativeConfig) => { Performance.subscribeToMeasurements((entry) => { if (entry.name === CONST.TIMING.SIDEBAR_LOADED) { console.debug('[E2E] Sidebar loaded, navigating to a report…'); + Performance.markStart(CONST.TIMING.OPEN_REPORT); Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(reportID)); return; } - if (entry.name === CONST.TIMING.REPORT_INITIAL_RENDER) { - console.debug('[E2E] Navigating to linked report action…'); - Timing.start(CONST.TIMING.SWITCH_REPORT); - Performance.markStart(CONST.TIMING.SWITCH_REPORT); - - Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(linkedReportID, linkedReportActionID)); - return; - } - - if (entry.name === CONST.TIMING.SWITCH_REPORT) { + if (entry.name === CONST.TIMING.OPEN_REPORT) { console.debug('[E2E] Linking: 1'); + console.debug('[E2E] Navigating to the linked report action…'); + Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(linkedReportID, linkedReportActionID)); E2EClient.submitTestResults({ branch: Config.E2E_BRANCH, @@ -80,7 +73,7 @@ const test = (config: NativeConfig) => { unit: 'ms', }); - switchReportResolve(); + openReportResolve(); } }); }); diff --git a/src/libs/E2E/tests/openSearchRouterTest.e2e.ts b/src/libs/E2E/tests/openSearchRouterTest.e2e.ts index de9464c9c286..4fd2b26e63c8 100644 --- a/src/libs/E2E/tests/openSearchRouterTest.e2e.ts +++ b/src/libs/E2E/tests/openSearchRouterTest.e2e.ts @@ -60,7 +60,7 @@ const test = (config: NativeConfig) => { props.onPress(); } - if (entry.name === CONST.TIMING.SEARCH_ROUTER_RENDER) { + if (entry.name === CONST.TIMING.OPEN_SEARCH) { E2EClient.submitTestResults({ branch: Config.E2E_BRANCH, name: `${name} Open Search Router TTI`, diff --git a/src/libs/E2E/tests/reportTypingTest.e2e.ts b/src/libs/E2E/tests/reportTypingTest.e2e.ts index e042a688c37d..473bf317e6c0 100644 --- a/src/libs/E2E/tests/reportTypingTest.e2e.ts +++ b/src/libs/E2E/tests/reportTypingTest.e2e.ts @@ -43,7 +43,7 @@ const test = (config: NativeConfig) => { }); Performance.subscribeToMeasurements((entry) => { - if (entry.name === CONST.TIMING.MESSAGE_SENT) { + if (entry.name === CONST.TIMING.SEND_MESSAGE) { E2EClient.submitTestResults({ branch: Config.E2E_BRANCH, name: `${name} Message sent`, diff --git a/src/libs/Navigation/AppNavigator/AuthScreens.tsx b/src/libs/Navigation/AppNavigator/AuthScreens.tsx index 58586250f958..a061d5b52d22 100644 --- a/src/libs/Navigation/AppNavigator/AuthScreens.tsx +++ b/src/libs/Navigation/AppNavigator/AuthScreens.tsx @@ -43,7 +43,6 @@ import * as PriorityMode from '@userActions/PriorityMode'; import * as Report from '@userActions/Report'; import * as Session from '@userActions/Session'; import toggleTestToolsModal from '@userActions/TestTool'; -import Timing from '@userActions/Timing'; import * as User from '@userActions/User'; import CONFIG from '@src/CONFIG'; import CONST from '@src/CONST'; @@ -248,8 +247,6 @@ function AuthScreens({session, lastOpenedPublicRoomID, initialLastUpdateIDApplie // eslint-disable-next-line react-compiler/react-compiler if (isInitialRender.current) { - Timing.start(CONST.TIMING.HOMEPAGE_INITIAL_RENDER); - const currentURL = getCurrentUrl(); if (currentURL) { initialReportID = new URL(currentURL).pathname.match(CONST.REGEX.REPORT_ID_FROM_PATH)?.at(1); @@ -313,8 +310,6 @@ function AuthScreens({session, lastOpenedPublicRoomID, initialLastUpdateIDApplie } Download.clearDownloads(); - Timing.end(CONST.TIMING.HOMEPAGE_INITIAL_RENDER); - const unsubscribeOnyxModal = onyxSubscribe({ key: ONYXKEYS.MODAL, callback: (modalArg) => { diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index ba859efff944..fc9601424080 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -1552,6 +1552,7 @@ type AuthScreensParamList = CentralPaneScreensParamList & type: ValueOf; accountID: string; isAuthTokenRequired?: string; + fileName?: string; }; [SCREENS.PROFILE_AVATAR]: { accountID: string; diff --git a/src/libs/Network/SequentialQueue.ts b/src/libs/Network/SequentialQueue.ts index 643ed64ae7f6..3f4da20c16e1 100644 --- a/src/libs/Network/SequentialQueue.ts +++ b/src/libs/Network/SequentialQueue.ts @@ -138,8 +138,8 @@ function flush() { return; } - if (PersistedRequests.getAll().length === 0) { - Log.info('[SequentialQueue] Unable to flush. No requests to process.'); + if (PersistedRequests.getAll().length === 0 && QueuedOnyxUpdates.isEmpty()) { + Log.info('[SequentialQueue] Unable to flush. No requests or queued Onyx updates to process.'); return; } diff --git a/src/libs/OptionsListUtils.ts b/src/libs/OptionsListUtils.ts index f6decd6fb2f4..6bcb353cf065 100644 --- a/src/libs/OptionsListUtils.ts +++ b/src/libs/OptionsListUtils.ts @@ -171,9 +171,6 @@ type GetOptionsConfig = { taxRates?: TaxRatesWithDefault; policy?: OnyxEntry; transaction?: OnyxEntry; - includePolicyReportFieldOptions?: boolean; - policyReportFieldOptions?: string[]; - recentlyUsedPolicyReportFieldOptions?: string[]; transactionViolations?: OnyxCollection; includeInvoiceRooms?: boolean; includeDomainEmail?: boolean; @@ -217,7 +214,6 @@ type Options = { categoryOptions: CategoryTreeSection[]; tagOptions: CategorySection[]; taxRatesOptions: CategorySection[]; - policyReportFieldOptions?: CategorySection[] | null; }; type PreviewConfig = {showChatPreviewLine?: boolean; forcePolicyNamePreview?: boolean; showPersonalDetails?: boolean}; @@ -1126,7 +1122,7 @@ function getCategoryListSections( const selectedOptionNames = selectedOptions.map((selectedOption) => selectedOption.name); const filteredCategories = enabledCategories.filter((category) => !selectedOptionNames.includes(category.name)); - if (numberOfEnabledCategories < CONST.CATEGORY_LIST_THRESHOLD) { + if (numberOfEnabledCategories < CONST.STANDARD_LIST_ITEM_LIMIT) { const data = getCategoryOptionTree(filteredCategories, false, selectedOptionsWithDisabledState); categorySections.push({ // "All" section when items amount less than the threshold @@ -1249,7 +1245,7 @@ function getTagListSections( return tagSections; } - if (numberOfTags < CONST.TAG_LIST_THRESHOLD) { + if (numberOfTags < CONST.STANDARD_LIST_ITEM_LIMIT) { tagSections.push({ // "All" section when items amount less than the threshold title: '', @@ -1309,81 +1305,6 @@ function hasEnabledTags(policyTagList: Array ({ - text: name, - keyForList: name, - searchText: name, - tooltipText: name, - isDisabled: false, - })); -} - -/** - * Build the section list for report field options - */ -function getReportFieldOptionsSection(options: string[], recentlyUsedOptions: string[], selectedOptions: Array>, searchInputValue: string) { - const reportFieldOptionsSections = []; - const selectedOptionKeys = selectedOptions.map(({text, keyForList, name}) => text ?? keyForList ?? name ?? '').filter((o) => !!o); - let indexOffset = 0; - - if (searchInputValue) { - const searchOptions = options.filter((option) => option.toLowerCase().includes(searchInputValue.toLowerCase())); - - reportFieldOptionsSections.push({ - // "Search" section - title: '', - shouldShow: true, - indexOffset, - data: getReportFieldOptions(searchOptions), - }); - - return reportFieldOptionsSections; - } - - const filteredRecentlyUsedOptions = recentlyUsedOptions.filter((recentlyUsedOption) => !selectedOptionKeys.includes(recentlyUsedOption)); - const filteredOptions = options.filter((option) => !selectedOptionKeys.includes(option)); - - if (selectedOptionKeys.length) { - reportFieldOptionsSections.push({ - // "Selected" section - title: '', - shouldShow: true, - indexOffset, - data: getReportFieldOptions(selectedOptionKeys), - }); - - indexOffset += selectedOptionKeys.length; - } - - if (filteredRecentlyUsedOptions.length > 0) { - reportFieldOptionsSections.push({ - // "Recent" section - title: Localize.translateLocal('common.recent'), - shouldShow: true, - indexOffset, - data: getReportFieldOptions(filteredRecentlyUsedOptions), - }); - - indexOffset += filteredRecentlyUsedOptions.length; - } - - reportFieldOptionsSections.push({ - // "All" section when items amount more than the threshold - title: Localize.translateLocal('common.all'), - shouldShow: true, - indexOffset, - data: getReportFieldOptions(filteredOptions), - }); - - return reportFieldOptionsSections; -} - /** * Sorts tax rates alphabetically by name. */ @@ -1459,7 +1380,7 @@ function getTaxRatesSection(policy: OnyxEntry | undefined, selectedOptio return policyRatesSections; } - if (numberOfTaxRates < CONST.TAX_RATES_LIST_THRESHOLD) { + if (numberOfTaxRates < CONST.STANDARD_LIST_ITEM_LIMIT) { policyRatesSections.push({ // "All" section when items amount less than the threshold title: '', @@ -1727,9 +1648,6 @@ function getOptions( policy, transaction, includeSelfDM = false, - includePolicyReportFieldOptions = false, - policyReportFieldOptions = [], - recentlyUsedPolicyReportFieldOptions = [], includeInvoiceRooms = false, includeDomainEmail = false, action, @@ -1779,20 +1697,6 @@ function getOptions( }; } - if (includePolicyReportFieldOptions) { - const transformedPolicyReportFieldOptions = getReportFieldOptionsSection(policyReportFieldOptions, recentlyUsedPolicyReportFieldOptions, selectedOptions, searchInputValue); - return { - recentReports: [], - personalDetails: [], - userToInvite: null, - currentUserOption: null, - categoryOptions: [], - tagOptions: [], - taxRatesOptions: [], - policyReportFieldOptions: transformedPolicyReportFieldOptions, - }; - } - const parsedPhoneNumber = PhoneNumber.parsePhoneNumber(LoginUtils.appendCountryCode(Str.removeSMSDomain(searchInputValue))); const searchValue = parsedPhoneNumber.possible ? parsedPhoneNumber.number?.e164 ?? '' : searchInputValue.toLowerCase(); const topmostReportId = Navigation.getTopmostReportId() ?? '-1'; @@ -2145,9 +2049,6 @@ type FilteredOptionsParams = { taxRates?: TaxRatesWithDefault; maxRecentReportsToShow?: number; includeSelfDM?: boolean; - includePolicyReportFieldOptions?: boolean; - policyReportFieldOptions?: string[]; - recentlyUsedPolicyReportFieldOptions?: string[]; includeInvoiceRooms?: boolean; action?: IOUAction; sortByReportTypeInSearch?: boolean; @@ -2186,9 +2087,6 @@ function getFilteredOptions(params: FilteredOptionsParamsWithDefaultSearchValue maxRecentReportsToShow = CONST.IOU.MAX_RECENT_REPORTS_TO_SHOW, taxRates = {} as TaxRatesWithDefault, includeSelfDM = false, - includePolicyReportFieldOptions = false, - policyReportFieldOptions = [], - recentlyUsedPolicyReportFieldOptions = [], includeInvoiceRooms = false, action, sortByReportTypeInSearch = false, @@ -2215,9 +2113,6 @@ function getFilteredOptions(params: FilteredOptionsParamsWithDefaultSearchValue includeTaxRates, taxRates, includeSelfDM, - includePolicyReportFieldOptions, - policyReportFieldOptions, - recentlyUsedPolicyReportFieldOptions, includeInvoiceRooms, action, sortByReportTypeInSearch, @@ -2260,9 +2155,6 @@ function getAttendeeOptions( maxRecentReportsToShow: 0, taxRates: {} as TaxRatesWithDefault, includeSelfDM: false, - includePolicyReportFieldOptions: false, - policyReportFieldOptions: [], - recentlyUsedPolicyReportFieldOptions: [], includeInvoiceRooms, action, sortByReportTypeInSearch, diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts index 48879c6b0a6e..4592500e9250 100644 --- a/src/libs/PolicyUtils.ts +++ b/src/libs/PolicyUtils.ts @@ -8,7 +8,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import INPUT_IDS from '@src/types/form/NetSuiteCustomFieldForm'; -import type {OnyxInputOrEntry, Policy, PolicyCategories, PolicyEmployeeList, PolicyTagLists, PolicyTags, TaxRate} from '@src/types/onyx'; +import type {OnyxInputOrEntry, Policy, PolicyCategories, PolicyEmployeeList, PolicyTagLists, PolicyTags, Report, TaxRate} from '@src/types/onyx'; import type {CardFeedData} from '@src/types/onyx/CardFeeds'; import type {ErrorFields, PendingAction, PendingFields} from '@src/types/onyx/OnyxCommon'; import type { @@ -31,10 +31,12 @@ import type { import type PolicyEmployee from '@src/types/onyx/PolicyEmployee'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import {hasSynchronizationErrorMessage} from './actions/connections'; +import {getCategoryApproverRule} from './CategoryUtils'; import * as Localize from './Localize'; import Navigation from './Navigation/Navigation'; import * as NetworkStore from './Network/NetworkStore'; import {getAccountIDsByLogins, getLoginsByAccountIDs, getPersonalDetailByEmail} from './PersonalDetailsUtils'; +import {getAllReportTransactions, getCategory, getTag} from './TransactionUtils'; type MemberEmailsToAccountIDs = Record; @@ -126,7 +128,7 @@ function getNumericValue(value: number | string, toLocaleDigit: (arg: string) => if (Number.isNaN(numValue)) { return NaN; } - return numValue; + return numValue.toFixed(CONST.CUSTOM_UNITS.RATE_DECIMALS); } /** @@ -158,10 +160,11 @@ function getRateDisplayValue(value: number, toLocaleDigit: (arg: string) => stri } if (withDecimals) { - const decimalPart = numValue.toString().split('.').at(1) ?? ''; - // Set the fraction digits to be between 2 and 4 (OD Behavior) - const fractionDigits = Math.min(Math.max(decimalPart.length, CONST.MIN_TAX_RATE_DECIMAL_PLACES), CONST.MAX_TAX_RATE_DECIMAL_PLACES); - return Number(numValue).toFixed(fractionDigits).toString().replace('.', toLocaleDigit('.')); + const decimalPart = numValue.toString().split('.').at(1); + if (decimalPart) { + const fixedDecimalPoints = decimalPart.length > 2 && !decimalPart.endsWith('0') ? 3 : 2; + return Number(numValue).toFixed(fixedDecimalPoints).toString().replace('.', toLocaleDigit('.')); + } } return numValue.toString().replace('.', toLocaleDigit('.')).substring(0, value.toString().length); @@ -525,12 +528,37 @@ function getDefaultApprover(policy: OnyxEntry): string { } /** - * Returns the accountID to whom the given employeeAccountID submits reports to in the given Policy. + * Returns the accountID to whom the given expenseReport submits reports to in the given Policy. */ -function getSubmitToAccountID(policy: OnyxEntry, employeeAccountID: number): number { +function getSubmitToAccountID(policy: OnyxEntry, expenseReport: OnyxEntry): number { + const employeeAccountID = expenseReport?.ownerAccountID ?? -1; const employeeLogin = getLoginsByAccountIDs([employeeAccountID]).at(0) ?? ''; const defaultApprover = getDefaultApprover(policy); + let categoryAppover; + let tagApprover; + const allTransactions = getAllReportTransactions(expenseReport?.reportID).sort((transA, transB) => (transA.created < transB.created ? -1 : 1)); + + // Before submitting to their `submitsTo` (in a policy on Advanced Approvals), submit to category/tag approvers. + // Category approvers are prioritized, then tag approvers. + for (let i = 0; i < allTransactions.length; i++) { + const transaction = allTransactions.at(i); + const tag = getTag(transaction); + const category = getCategory(transaction); + categoryAppover = getCategoryApproverRule(policy?.rules?.approvalRules ?? [], category)?.approver; + if (categoryAppover) { + return getAccountIDsByLogins([categoryAppover]).at(0) ?? -1; + } + + if (!tagApprover && getTagApproverRule(policy?.id ?? '-1', tag)?.approver) { + tagApprover = getTagApproverRule(policy?.id ?? '-1', tag)?.approver; + } + } + + if (tagApprover) { + return getAccountIDsByLogins([tagApprover]).at(0) ?? -1; + } + // For policy using the optional or basic workflow, the manager is the policy default approver. if (([CONST.POLICY.APPROVAL_MODE.OPTIONAL, CONST.POLICY.APPROVAL_MODE.BASIC] as Array>).includes(getApprovalWorkflow(policy))) { return getAccountIDsByLogins([defaultApprover]).at(0) ?? -1; @@ -544,8 +572,8 @@ function getSubmitToAccountID(policy: OnyxEntry, employeeAccountID: numb return getAccountIDsByLogins([employee.submitsTo ?? defaultApprover]).at(0) ?? -1; } -function getSubmitToEmail(policy: OnyxEntry, employeeAccountID: number): string { - const submitToAccountID = getSubmitToAccountID(policy, employeeAccountID); +function getSubmitToEmail(policy: OnyxEntry, expenseReport: OnyxEntry): string { + const submitToAccountID = getSubmitToAccountID(policy, expenseReport); return getLoginsByAccountIDs([submitToAccountID]).at(0) ?? ''; } diff --git a/src/libs/ReportFieldOptionsListUtils.ts b/src/libs/ReportFieldOptionsListUtils.ts new file mode 100644 index 000000000000..b3b302fdaaea --- /dev/null +++ b/src/libs/ReportFieldOptionsListUtils.ts @@ -0,0 +1,90 @@ +import * as Localize from './Localize'; +import type {Option} from './OptionsListUtils'; +import type * as ReportUtils from './ReportUtils'; + +/** + * Transforms the provided report field options into option objects. + * + * @param reportFieldOptions - an initial report field options array + */ +function getReportFieldOptions(reportFieldOptions: string[]): Option[] { + return reportFieldOptions.map((name) => ({ + text: name, + keyForList: name, + searchText: name, + tooltipText: name, + isDisabled: false, + })); +} + +/** + * Build the section list for report field options + */ +function getReportFieldOptionsSection({ + options, + recentlyUsedOptions, + selectedOptions, + searchValue, +}: { + options: string[]; + recentlyUsedOptions: string[]; + selectedOptions: Array>; + searchValue: string; +}) { + const reportFieldOptionsSections = []; + const selectedOptionKeys = selectedOptions.map(({text, keyForList, name}) => text ?? keyForList ?? name ?? '').filter((o) => !!o); + let indexOffset = 0; + + if (searchValue) { + const searchOptions = options.filter((option) => option.toLowerCase().includes(searchValue.toLowerCase())); + + reportFieldOptionsSections.push({ + // "Search" section + title: '', + shouldShow: true, + indexOffset, + data: getReportFieldOptions(searchOptions), + }); + + return reportFieldOptionsSections; + } + + const filteredRecentlyUsedOptions = recentlyUsedOptions.filter((recentlyUsedOption) => !selectedOptionKeys.includes(recentlyUsedOption)); + const filteredOptions = options.filter((option) => !selectedOptionKeys.includes(option)); + + if (selectedOptionKeys.length) { + reportFieldOptionsSections.push({ + // "Selected" section + title: '', + shouldShow: true, + indexOffset, + data: getReportFieldOptions(selectedOptionKeys), + }); + + indexOffset += selectedOptionKeys.length; + } + + if (filteredRecentlyUsedOptions.length > 0) { + reportFieldOptionsSections.push({ + // "Recent" section + title: Localize.translateLocal('common.recent'), + shouldShow: true, + indexOffset, + data: getReportFieldOptions(filteredRecentlyUsedOptions), + }); + + indexOffset += filteredRecentlyUsedOptions.length; + } + + reportFieldOptionsSections.push({ + // "All" section when items amount more than the threshold + title: Localize.translateLocal('common.all'), + shouldShow: true, + indexOffset, + data: getReportFieldOptions(filteredOptions), + }); + + return reportFieldOptionsSections; +} + +export {getReportFieldOptionsSection, getReportFieldOptions}; diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 00e2c283e934..28e3a0144141 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -1311,7 +1311,7 @@ function isAwaitingFirstLevelApproval(report: OnyxEntry): boolean { return false; } - const submitsToAccountID = PolicyUtils.getSubmitToAccountID(getPolicy(report.policyID), report.ownerAccountID ?? -1); + const submitsToAccountID = PolicyUtils.getSubmitToAccountID(getPolicy(report.policyID), report); return isProcessingReport(report) && submitsToAccountID === report.managerID; } @@ -1634,8 +1634,9 @@ function isIOURequest(report: OnyxInputOrEntry): boolean { */ function isTrackExpenseReport(report: OnyxInputOrEntry): boolean { if (isThread(report)) { + const selfDMReportID = findSelfDMReportID(); const parentReportAction = allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.parentReportID}`]?.[report.parentReportActionID]; - return !isEmptyObject(parentReportAction) && ReportActionsUtils.isTrackExpenseAction(parentReportAction); + return !isEmptyObject(parentReportAction) && selfDMReportID === report.parentReportID && ReportActionsUtils.isTrackExpenseAction(parentReportAction); } return false; } @@ -1700,6 +1701,16 @@ function isOneTransactionThread(reportID: string, parentReportID: string, thread return reportID === transactionThreadReportID && !ReportActionsUtils.isSentMoneyReportAction(threadParentReportAction); } +/** + * Get displayed report ID, it will be parentReportID if the report is one transaction thread + */ +function getDisplayedReportID(reportID: string): string { + const report = getReport(reportID); + const parentReportID = report?.parentReportID ?? ''; + const parentReportAction = ReportActionsUtils.getReportAction(parentReportID, report?.parentReportActionID ?? ''); + return isOneTransactionThread(reportID, parentReportID, parentReportAction) ? parentReportID : reportID; +} + /** * Should return true only for personal 1:1 report * @@ -3162,7 +3173,7 @@ function canEditMoneyRequest(reportAction: OnyxInputOrEntry, approverAcco function isAllowedToSubmitDraftExpenseReport(report: OnyxEntry): boolean { const policy = getPolicy(report?.policyID); - const submitToAccountID = PolicyUtils.getSubmitToAccountID(policy, report?.ownerAccountID ?? -1); + const submitToAccountID = PolicyUtils.getSubmitToAccountID(policy, report); return isAllowedToApproveExpenseReport(report, submitToAccountID); } @@ -8374,15 +8383,16 @@ function isExported(reportActions: OnyxEntry) { return Object.values(reportActions).some((action) => ReportActionsUtils.isExportIntegrationAction(action)); } -function getApprovalChain(policy: OnyxEntry, employeeAccountID: number, reportTotal: number): string[] { +function getApprovalChain(policy: OnyxEntry, expenseReport: OnyxEntry): string[] { const approvalChain: string[] = []; + const reportTotal = expenseReport?.total ?? 0; // If the policy is not on advanced approval mode, we should not use the approval chain even if it exists. if (!PolicyUtils.isControlOnAdvancedApprovalMode(policy)) { return approvalChain; } - let nextApproverEmail = PolicyUtils.getSubmitToEmail(policy, employeeAccountID); + let nextApproverEmail = PolicyUtils.getSubmitToEmail(policy, expenseReport); while (nextApproverEmail && !approvalChain.includes(nextApproverEmail)) { approvalChain.push(nextApproverEmail); @@ -8560,6 +8570,7 @@ export { getTaskAssigneeChatOnyxData, getTransactionDetails, getTransactionReportName, + getDisplayedReportID, getTransactionsWithReceipts, getUserDetailTooltipText, getWhisperDisplayNames, diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts index 99a97ea08672..aa46d28e6899 100644 --- a/src/libs/TransactionUtils/index.ts +++ b/src/libs/TransactionUtils/index.ts @@ -248,7 +248,19 @@ function areRequiredFieldsEmpty(transaction: OnyxEntry): boolean { /** * Given the edit made to the expense, return an updated transaction object. */ -function getUpdatedTransaction(transaction: Transaction, transactionChanges: TransactionChanges, isFromExpenseReport: boolean, shouldUpdateReceiptState = true): Transaction { +function getUpdatedTransaction({ + transaction, + transactionChanges, + isFromExpenseReport, + shouldUpdateReceiptState = true, + policy = undefined, +}: { + transaction: Transaction; + transactionChanges: TransactionChanges; + isFromExpenseReport: boolean; + shouldUpdateReceiptState?: boolean; + policy?: OnyxEntry; +}): Transaction { // Only changing the first level fields so no need for deep clone now const updatedTransaction = lodashDeepClone(transaction); let shouldStopSmartscan = false; @@ -280,7 +292,29 @@ function getUpdatedTransaction(transaction: Transaction, transactionChanges: Tra if (Object.hasOwn(transactionChanges, 'waypoints')) { updatedTransaction.modifiedWaypoints = transactionChanges.waypoints; + updatedTransaction.isLoading = true; shouldStopSmartscan = true; + + if (!transactionChanges.routes?.route0?.geometry?.coordinates) { + // The waypoints were changed, but there is no route – it is pending from the BE and we should mark the fields as pending + updatedTransaction.amount = CONST.IOU.DEFAULT_AMOUNT; + updatedTransaction.modifiedAmount = CONST.IOU.DEFAULT_AMOUNT; + updatedTransaction.modifiedMerchant = Localize.translateLocal('iou.fieldPending'); + } else { + const mileageRate = DistanceRequestUtils.getRate({transaction: updatedTransaction, policy}); + const {unit, rate} = mileageRate; + + const distanceInMeters = getDistanceInMeters(transaction, unit); + const amount = DistanceRequestUtils.getDistanceRequestAmount(distanceInMeters, unit, rate ?? 0); + const updatedAmount = isFromExpenseReport ? -amount : amount; + const updatedMerchant = DistanceRequestUtils.getDistanceMerchant(true, distanceInMeters, unit, rate, transaction.currency, Localize.translateLocal, (digit) => + toLocaleDigit(preferredLocale, digit), + ); + + updatedTransaction.amount = updatedAmount; + updatedTransaction.modifiedAmount = updatedAmount; + updatedTransaction.modifiedMerchant = updatedMerchant; + } } if (Object.hasOwn(transactionChanges, 'customUnitRateID')) { @@ -289,20 +323,38 @@ function getUpdatedTransaction(transaction: Transaction, transactionChanges: Tra shouldStopSmartscan = true; const existingDistanceUnit = transaction?.comment?.customUnit?.distanceUnit; - const allReports = ReportConnection.getAllReports(); - const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${transaction.reportID}`] ?? null; - const policyID = report?.policyID ?? ''; - const policy = PolicyUtils.getPolicy(policyID); // Get the new distance unit from the rate's unit const newDistanceUnit = DistanceRequestUtils.getUpdatedDistanceUnit({transaction: updatedTransaction, policy}); + lodashSet(updatedTransaction, 'comment.customUnit.distanceUnit', newDistanceUnit); // If the distanceUnit is set and the rate is changed to one that has a different unit, convert the distance to the new unit if (existingDistanceUnit && newDistanceUnit !== existingDistanceUnit) { const conversionFactor = existingDistanceUnit === CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES ? CONST.CUSTOM_UNITS.MILES_TO_KILOMETERS : CONST.CUSTOM_UNITS.KILOMETERS_TO_MILES; const distance = NumberUtils.roundToTwoDecimalPlaces((transaction?.comment?.customUnit?.quantity ?? 0) * conversionFactor); lodashSet(updatedTransaction, 'comment.customUnit.quantity', distance); - lodashSet(updatedTransaction, 'pendingFields.merchant', CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE); + } + + if (!isFetchingWaypointsFromServer(transaction)) { + // When the waypoints are being fetched from the server, we have no information about the distance, and cannot recalculate the updated amount. + // Otherwise, recalculate the fields based on the new rate. + + const oldMileageRate = DistanceRequestUtils.getRate({transaction, policy}); + const updatedMileageRate = DistanceRequestUtils.getRate({transaction: updatedTransaction, policy, useTransactionDistanceUnit: false}); + const {unit, rate} = updatedMileageRate; + + const distanceInMeters = getDistanceInMeters(transaction, oldMileageRate?.unit); + const amount = DistanceRequestUtils.getDistanceRequestAmount(distanceInMeters, unit, rate ?? 0); + const updatedAmount = isFromExpenseReport ? -amount : amount; + const updatedCurrency = updatedMileageRate.currency ?? CONST.CURRENCY.USD; + const updatedMerchant = DistanceRequestUtils.getDistanceMerchant(true, distanceInMeters, unit, rate, updatedCurrency, Localize.translateLocal, (digit) => + toLocaleDigit(preferredLocale, digit), + ); + + updatedTransaction.amount = updatedAmount; + updatedTransaction.modifiedAmount = updatedAmount; + updatedTransaction.modifiedMerchant = updatedMerchant; + updatedTransaction.modifiedCurrency = updatedCurrency; } } @@ -860,49 +912,6 @@ function calculateTaxAmount(percentage: string, amount: number, currency: string return parseFloat(taxAmount.toFixed(decimals)); } -/** - * Calculates updated amount, currency, and merchant for a distance request with modified waypoints or customUnitRateID - */ -function calculateAmountForUpdatedWaypointOrRate( - transaction: OnyxInputOrEntry, - transactionChanges: TransactionChanges, - policy: OnyxInputOrEntry, - isFromExpenseReport: boolean, -) { - const hasModifiedRouteWithPendingWaypoints = !isEmptyObject(transactionChanges.waypoints) && isEmptyObject(transactionChanges?.routes?.route0?.geometry); - const hasModifiedRateWithPendingWaypoints = !!transactionChanges?.customUnitRateID && isFetchingWaypointsFromServer(transaction); - if (hasModifiedRouteWithPendingWaypoints || hasModifiedRateWithPendingWaypoints) { - return { - amount: CONST.IOU.DEFAULT_AMOUNT, - modifiedAmount: CONST.IOU.DEFAULT_AMOUNT, - modifiedMerchant: Localize.translateLocal('iou.fieldPending'), - }; - } - - const customUnitRateID = transactionChanges.customUnitRateID ?? getRateID(transaction) ?? ''; - const mileageRates = DistanceRequestUtils.getMileageRates(policy, true); - const policyCurrency = policy?.outputCurrency ?? PolicyUtils.getPersonalPolicy()?.outputCurrency ?? CONST.CURRENCY.USD; - const mileageRate = isCustomUnitRateIDForP2P(transaction) - ? DistanceRequestUtils.getRateForP2P(policyCurrency, transaction ?? undefined) - : mileageRates?.[customUnitRateID] ?? DistanceRequestUtils.getDefaultMileageRate(policy); - const {unit, rate, currency} = mileageRate; - - const distanceInMeters = getDistanceInMeters(transaction, unit); - const amount = DistanceRequestUtils.getDistanceRequestAmount(distanceInMeters, unit, rate ?? 0); - const updatedAmount = isFromExpenseReport ? -amount : amount; - const updatedCurrency = currency ?? CONST.CURRENCY.USD; - const updatedMerchant = DistanceRequestUtils.getDistanceMerchant(true, distanceInMeters, unit, rate, updatedCurrency, Localize.translateLocal, (digit) => - toLocaleDigit(preferredLocale, digit), - ); - - return { - amount: updatedAmount, - modifiedAmount: updatedAmount, - modifiedMerchant: updatedMerchant, - modifiedCurrency: updatedCurrency, - }; -} - /** * Calculates count of all tax enabled options */ @@ -1215,7 +1224,6 @@ function buildTransactionsMergeParams(reviewDuplicates: OnyxEntry { @@ -170,6 +174,8 @@ function disconnect() { NetworkStore.setAuthToken(response?.authToken ?? null); confirmReadyToOpenApp(); openApp(); + + NativeModules.HybridAppModule.switchAccount(getCurrentUserEmail() ?? ''); }); }) .catch((error) => { @@ -607,4 +613,5 @@ export { clearDelegateRolePendingAction, updateDelegateRole, removeDelegate, + KEYS_TO_PRESERVE_DELEGATE_ACCESS, }; diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts index fc37c2f781df..5ec2e81b8c01 100644 --- a/src/libs/actions/IOU.ts +++ b/src/libs/actions/IOU.ts @@ -1,6 +1,5 @@ import {format} from 'date-fns'; import {fastMerge, Str} from 'expensify-common'; -import lodashSet from 'lodash/set'; import type {NullishDeep, OnyxCollection, OnyxEntry, OnyxInputValue, OnyxUpdate} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; import type {PartialDeep, SetRequired, ValueOf} from 'type-fest'; @@ -13,7 +12,6 @@ import type { CreateWorkspaceParams, DeleteMoneyRequestParams, DetachReceiptParams, - EditMoneyRequestParams, PayInvoiceParams, PayMoneyRequestParams, ReplaceReceiptParams, @@ -2487,10 +2485,6 @@ function calculateDiffAmount( * @param policy May be undefined, an empty object, or an object matching the Policy type (src/types/onyx/Policy.ts) * @param policyTagList * @param policyCategories - * @param onlyIncludeChangedFields - * When 'true', then the returned params will only include the transaction details for the fields that were changed. - * When `false`, then the returned params will include all the transaction details, regardless of which fields were changed. - * This setting is necessary while the UpdateDistanceRequest API is refactored to be fully 1:1:1 in https://github.com/Expensify/App/issues/28358 */ function getUpdateMoneyRequestParams( transactionID: string, @@ -2499,7 +2493,6 @@ function getUpdateMoneyRequestParams( policy: OnyxEntry, policyTagList: OnyxTypes.OnyxInputOrEntry, policyCategories: OnyxTypes.OnyxInputOrEntry, - onlyIncludeChangedFields: boolean, violations?: OnyxEntry, ): UpdateMoneyRequestData { const optimisticData: OnyxUpdate[] = []; @@ -2507,7 +2500,8 @@ function getUpdateMoneyRequestParams( const failureData: OnyxUpdate[] = []; // Step 1: Set any "pending fields" (ones updated while the user was offline) to have error messages in the failureData - let pendingFields: OnyxTypes.Transaction['pendingFields'] = Object.fromEntries(Object.keys(transactionChanges).map((key) => [key, CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE])); + const pendingFields: OnyxTypes.Transaction['pendingFields'] = Object.fromEntries(Object.keys(transactionChanges).map((key) => [key, CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE])); + const clearedPendingFields = Object.fromEntries(Object.keys(transactionChanges).map((key) => [key, null])); const errorFields = Object.fromEntries(Object.keys(pendingFields).map((key) => [key, {[DateUtils.getMicroseconds()]: Localize.translateLocal('iou.error.genericEditFailureMessage')}])); const allReports = ReportConnection.getAllReports(); @@ -2517,24 +2511,22 @@ function getUpdateMoneyRequestParams( const iouReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${transactionThread?.parentReportID}`] ?? null; const isFromExpenseReport = ReportUtils.isExpenseReport(iouReport); const isScanning = TransactionUtils.hasReceipt(transaction) && TransactionUtils.isReceiptBeingScanned(transaction); - let updatedTransaction: OnyxEntry = transaction ? TransactionUtils.getUpdatedTransaction(transaction, transactionChanges, isFromExpenseReport) : undefined; + const updatedTransaction: OnyxEntry = transaction + ? TransactionUtils.getUpdatedTransaction({ + transaction, + transactionChanges, + isFromExpenseReport, + policy, + }) + : undefined; const transactionDetails = ReportUtils.getTransactionDetails(updatedTransaction); - if (updatedTransaction?.pendingFields) { - pendingFields = { - ...pendingFields, - ...updatedTransaction?.pendingFields, - }; - } - if (transactionDetails?.waypoints) { // This needs to be a JSON string since we're sending this to the MapBox API transactionDetails.waypoints = JSON.stringify(transactionDetails.waypoints); } - const dataToIncludeInParams: Partial | undefined = onlyIncludeChangedFields - ? Object.fromEntries(Object.entries(transactionDetails ?? {}).filter(([key]) => Object.keys(transactionChanges).includes(key))) - : transactionDetails; + const dataToIncludeInParams: Partial = Object.fromEntries(Object.entries(transactionDetails ?? {}).filter(([key]) => Object.keys(transactionChanges).includes(key))); const params: UpdateMoneyRequestParams = { ...dataToIncludeInParams, @@ -2545,14 +2537,6 @@ function getUpdateMoneyRequestParams( const hasPendingWaypoints = 'waypoints' in transactionChanges; const hasModifiedDistanceRate = 'customUnitRateID' in transactionChanges; if (transaction && updatedTransaction && (hasPendingWaypoints || hasModifiedDistanceRate)) { - updatedTransaction = { - ...updatedTransaction, - ...TransactionUtils.calculateAmountForUpdatedWaypointOrRate(updatedTransaction, transactionChanges, policy, ReportUtils.isExpenseReport(iouReport)), - }; - - // Update the distanceUnit - lodashSet(updatedTransaction, 'comment.customUnit.distanceUnit', DistanceRequestUtils.getUpdatedDistanceUnit({transaction: updatedTransaction, policy})); - // Delete the draft transaction when editing waypoints when the server responds successfully and there are no errors successData.push({ onyxMethod: Onyx.METHOD.SET, @@ -2569,6 +2553,7 @@ function getUpdateMoneyRequestParams( amount: transaction.amount, modifiedAmount: transaction.modifiedAmount, modifiedMerchant: transaction.modifiedMerchant, + modifiedCurrency: transaction.modifiedCurrency, }, }); } @@ -2673,7 +2658,6 @@ function getUpdateMoneyRequestParams( value: { ...updatedTransaction, pendingFields, - isLoading: hasPendingWaypoints, errorFields: null, }, }); @@ -2764,8 +2748,6 @@ function getUpdateMoneyRequestParams( } } - const clearedPendingFields = Object.fromEntries(Object.keys(updatedTransaction?.pendingFields ?? transactionChanges).map((key) => [key, null])); - // Clear out the error fields and loading states on success successData.push({ onyxMethod: Onyx.METHOD.MERGE, @@ -2835,18 +2817,13 @@ function getUpdateMoneyRequestParams( * @param transactionThreadReportID * @param transactionChanges * @param [transactionChanges.created] Present when updated the date field - * @param onlyIncludeChangedFields - * When 'true', then the returned params will only include the transaction details for the fields that were changed. - * When `false`, then the returned params will include all the transaction details, regardless of which fields were changed. - * This setting is necessary while the UpdateDistanceRequest API is refactored to be fully 1:1:1 in https://github.com/Expensify/App/issues/28358 * @param policy May be undefined, an empty object, or an object matching the Policy type (src/types/onyx/Policy.ts) */ function getUpdateTrackExpenseParams( transactionID: string, transactionThreadReportID: string, transactionChanges: TransactionChanges, - onlyIncludeChangedFields: boolean, - policy: OnyxTypes.OnyxInputOrEntry, + policy: OnyxEntry, ): UpdateMoneyRequestData { const optimisticData: OnyxUpdate[] = []; const successData: OnyxUpdate[] = []; @@ -2854,6 +2831,7 @@ function getUpdateTrackExpenseParams( // Step 1: Set any "pending fields" (ones updated while the user was offline) to have error messages in the failureData const pendingFields = Object.fromEntries(Object.keys(transactionChanges).map((key) => [key, CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE])); + const clearedPendingFields = Object.fromEntries(Object.keys(transactionChanges).map((key) => [key, null])); const errorFields = Object.fromEntries(Object.keys(pendingFields).map((key) => [key, {[DateUtils.getMicroseconds()]: Localize.translateLocal('iou.error.genericEditFailureMessage')}])); const allReports = ReportConnection.getAllReports(); @@ -2862,7 +2840,14 @@ function getUpdateTrackExpenseParams( const transaction = allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]; const chatReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${transactionThread?.parentReportID}`] ?? null; const isScanning = TransactionUtils.hasReceipt(transaction) && TransactionUtils.isReceiptBeingScanned(transaction); - let updatedTransaction = transaction ? TransactionUtils.getUpdatedTransaction(transaction, transactionChanges, false) : null; + const updatedTransaction = transaction + ? TransactionUtils.getUpdatedTransaction({ + transaction, + transactionChanges, + isFromExpenseReport: false, + policy, + }) + : null; const transactionDetails = ReportUtils.getTransactionDetails(updatedTransaction); if (transactionDetails?.waypoints) { @@ -2870,9 +2855,7 @@ function getUpdateTrackExpenseParams( transactionDetails.waypoints = JSON.stringify(transactionDetails.waypoints); } - const dataToIncludeInParams: Partial | undefined = onlyIncludeChangedFields - ? Object.fromEntries(Object.entries(transactionDetails ?? {}).filter(([key]) => Object.keys(transactionChanges).includes(key))) - : transactionDetails; + const dataToIncludeInParams: Partial = Object.fromEntries(Object.entries(transactionDetails ?? {}).filter(([key]) => Object.keys(transactionChanges).includes(key))); const params: UpdateMoneyRequestParams = { ...dataToIncludeInParams, @@ -2883,11 +2866,6 @@ function getUpdateTrackExpenseParams( const hasPendingWaypoints = 'waypoints' in transactionChanges; const hasModifiedDistanceRate = 'customUnitRateID' in transactionChanges; if (transaction && updatedTransaction && (hasPendingWaypoints || hasModifiedDistanceRate)) { - updatedTransaction = { - ...updatedTransaction, - ...TransactionUtils.calculateAmountForUpdatedWaypointOrRate(transaction, transactionChanges, policy, false), - }; - // Delete the draft transaction when editing waypoints when the server responds successfully and there are no errors successData.push({ onyxMethod: Onyx.METHOD.SET, @@ -2914,7 +2892,7 @@ function getUpdateTrackExpenseParams( // - we're updating the distance rate while the waypoints are still pending // In these cases, there isn't a valid optimistic mileage data we can use, // and the report action is created on the server with the distance-related response from the MapBox API - const updatedReportAction = ReportUtils.buildOptimisticModifiedExpenseReportAction(transactionThread, transaction, transactionChanges, false, policy); + const updatedReportAction = ReportUtils.buildOptimisticModifiedExpenseReportAction(transactionThread, transaction, transactionChanges, false, policy, updatedTransaction); if (!hasPendingWaypoints && !(hasModifiedDistanceRate && TransactionUtils.isFetchingWaypointsFromServer(transaction))) { params.reportActionID = updatedReportAction.reportActionID; @@ -2952,7 +2930,6 @@ function getUpdateTrackExpenseParams( value: { ...updatedTransaction, pendingFields, - isLoading: hasPendingWaypoints, errorFields: null, }, }); @@ -2979,8 +2956,6 @@ function getUpdateTrackExpenseParams( }); } - const clearedPendingFields = Object.fromEntries(Object.keys(updatedTransaction?.pendingFields ?? transactionChanges).map((key) => [key, null])); - // Clear out the error fields and loading states on success successData.push({ onyxMethod: Onyx.METHOD.MERGE, @@ -3034,9 +3009,9 @@ function updateMoneyRequestDate( const parentReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReport?.parentReportID}`] ?? null; let data: UpdateMoneyRequestData; if (ReportUtils.isTrackExpenseReport(transactionThreadReport) && ReportUtils.isSelfDM(parentReport)) { - data = getUpdateTrackExpenseParams(transactionID, transactionThreadReportID, transactionChanges, true, policy); + data = getUpdateTrackExpenseParams(transactionID, transactionThreadReportID, transactionChanges, policy); } else { - data = getUpdateMoneyRequestParams(transactionID, transactionThreadReportID, transactionChanges, policy, policyTags, policyCategories, true); + data = getUpdateMoneyRequestParams(transactionID, transactionThreadReportID, transactionChanges, policy, policyTags, policyCategories); } const {params, onyxData} = data; API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST_DATE, params, onyxData); @@ -3054,7 +3029,7 @@ function updateMoneyRequestBillable( const transactionChanges: TransactionChanges = { billable: value, }; - const {params, onyxData} = getUpdateMoneyRequestParams(transactionID, transactionThreadReportID, transactionChanges, policy, policyTagList, policyCategories, true); + const {params, onyxData} = getUpdateMoneyRequestParams(transactionID, transactionThreadReportID, transactionChanges, policy, policyTagList, policyCategories); API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST_BILLABLE, params, onyxData); } @@ -3075,9 +3050,9 @@ function updateMoneyRequestMerchant( const parentReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReport?.parentReportID}`] ?? null; let data: UpdateMoneyRequestData; if (ReportUtils.isTrackExpenseReport(transactionThreadReport) && ReportUtils.isSelfDM(parentReport)) { - data = getUpdateTrackExpenseParams(transactionID, transactionThreadReportID, transactionChanges, true, policy); + data = getUpdateTrackExpenseParams(transactionID, transactionThreadReportID, transactionChanges, policy); } else { - data = getUpdateMoneyRequestParams(transactionID, transactionThreadReportID, transactionChanges, policy, policyTagList, policyCategories, true); + data = getUpdateMoneyRequestParams(transactionID, transactionThreadReportID, transactionChanges, policy, policyTagList, policyCategories); } const {params, onyxData} = data; API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST_MERCHANT, params, onyxData); @@ -3096,7 +3071,7 @@ function updateMoneyRequestAttendees( const transactionChanges: TransactionChanges = { attendees, }; - const data = getUpdateMoneyRequestParams(transactionID, transactionThreadReportID, transactionChanges, policy, policyTagList, policyCategories, true, violations); + const data = getUpdateMoneyRequestParams(transactionID, transactionThreadReportID, transactionChanges, policy, policyTagList, policyCategories, violations); const {params, onyxData} = data; API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST_ATTENDEES, params, onyxData); } @@ -3113,7 +3088,7 @@ function updateMoneyRequestTag( const transactionChanges: TransactionChanges = { tag, }; - const {params, onyxData} = getUpdateMoneyRequestParams(transactionID, transactionThreadReportID, transactionChanges, policy, policyTagList, policyCategories, true); + const {params, onyxData} = getUpdateMoneyRequestParams(transactionID, transactionThreadReportID, transactionChanges, policy, policyTagList, policyCategories); API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST_TAG, params, onyxData); } @@ -3129,7 +3104,7 @@ function updateMoneyRequestTaxAmount( const transactionChanges = { taxAmount, }; - const {params, onyxData} = getUpdateMoneyRequestParams(transactionID, optimisticReportActionID, transactionChanges, policy, policyTagList, policyCategories, true); + const {params, onyxData} = getUpdateMoneyRequestParams(transactionID, optimisticReportActionID, transactionChanges, policy, policyTagList, policyCategories); API.write('UpdateMoneyRequestTaxAmount', params, onyxData); } @@ -3149,7 +3124,7 @@ function updateMoneyRequestTaxRate({transactionID, optimisticReportActionID, tax taxCode, taxAmount, }; - const {params, onyxData} = getUpdateMoneyRequestParams(transactionID, optimisticReportActionID, transactionChanges, policy, policyTagList, policyCategories, true); + const {params, onyxData} = getUpdateMoneyRequestParams(transactionID, optimisticReportActionID, transactionChanges, policy, policyTagList, policyCategories); API.write('UpdateMoneyRequestTaxRate', params, onyxData); } @@ -3182,9 +3157,9 @@ function updateMoneyRequestDistance({ const parentReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReport?.parentReportID}`] ?? null; let data: UpdateMoneyRequestData; if (ReportUtils.isTrackExpenseReport(transactionThreadReport) && ReportUtils.isSelfDM(parentReport)) { - data = getUpdateTrackExpenseParams(transactionID, transactionThreadReportID, transactionChanges, true, policy); + data = getUpdateTrackExpenseParams(transactionID, transactionThreadReportID, transactionChanges, policy); } else { - data = getUpdateMoneyRequestParams(transactionID, transactionThreadReportID, transactionChanges, policy, policyTagList, policyCategories, true); + data = getUpdateMoneyRequestParams(transactionID, transactionThreadReportID, transactionChanges, policy, policyTagList, policyCategories); } const {params, onyxData} = data; @@ -3210,7 +3185,7 @@ function updateMoneyRequestCategory( const transactionChanges: TransactionChanges = { category, }; - const {params, onyxData} = getUpdateMoneyRequestParams(transactionID, transactionThreadReportID, transactionChanges, policy, policyTagList, policyCategories, true); + const {params, onyxData} = getUpdateMoneyRequestParams(transactionID, transactionThreadReportID, transactionChanges, policy, policyTagList, policyCategories); API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST_CATEGORY, params, onyxData); } @@ -3231,9 +3206,9 @@ function updateMoneyRequestDescription( const parentReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReport?.parentReportID}`] ?? null; let data: UpdateMoneyRequestData; if (ReportUtils.isTrackExpenseReport(transactionThreadReport) && ReportUtils.isSelfDM(parentReport)) { - data = getUpdateTrackExpenseParams(transactionID, transactionThreadReportID, transactionChanges, true, policy); + data = getUpdateTrackExpenseParams(transactionID, transactionThreadReportID, transactionChanges, policy); } else { - data = getUpdateMoneyRequestParams(transactionID, transactionThreadReportID, transactionChanges, policy, policyTagList, policyCategories, true); + data = getUpdateMoneyRequestParams(transactionID, transactionThreadReportID, transactionChanges, policy, policyTagList, policyCategories); } const {params, onyxData} = data; API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST_DESCRIPTION, params, onyxData); @@ -3258,11 +3233,23 @@ function updateMoneyRequestDistanceRate( const allReports = ReportConnection.getAllReports(); const transactionThreadReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`] ?? null; const parentReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReport?.parentReportID}`] ?? null; + + const transaction = allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]; + if (transaction) { + const existingDistanceUnit = transaction?.comment?.customUnit?.distanceUnit; + const newDistanceUnit = DistanceRequestUtils.getRateByCustomUnitRateID({customUnitRateID: rateID, policy})?.unit; + + // If the distanceUnit is set and the rate is changed to one that has a different unit, mark the merchant as modified to make the distance field pending + if (existingDistanceUnit && newDistanceUnit && newDistanceUnit !== existingDistanceUnit) { + transactionChanges.merchant = TransactionUtils.getMerchant(transaction); + } + } + let data: UpdateMoneyRequestData; if (ReportUtils.isTrackExpenseReport(transactionThreadReport) && ReportUtils.isSelfDM(parentReport)) { - data = getUpdateTrackExpenseParams(transactionID, transactionThreadReportID, transactionChanges, true, policy); + data = getUpdateTrackExpenseParams(transactionID, transactionThreadReportID, transactionChanges, policy); } else { - data = getUpdateMoneyRequestParams(transactionID, transactionThreadReportID, transactionChanges, policy, policyTagList, policyCategories, true); + data = getUpdateMoneyRequestParams(transactionID, transactionThreadReportID, transactionChanges, policy, policyTagList, policyCategories); } const {params, onyxData} = data; // `taxAmount` & `taxCode` only needs to be updated in the optimistic data, so we need to remove them from the params @@ -3270,19 +3257,6 @@ function updateMoneyRequestDistanceRate( API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST_DISTANCE_RATE, paramsWithoutTaxUpdated, onyxData); } -/** Edits an existing distance expense */ -function updateDistanceRequest( - transactionID: string, - transactionThreadReportID: string, - transactionChanges: TransactionChanges, - policy: OnyxTypes.Policy, - policyTagList: OnyxTypes.PolicyTagLists, - policyCategories: OnyxTypes.PolicyCategories, -) { - const {params, onyxData} = getUpdateMoneyRequestParams(transactionID, transactionThreadReportID, transactionChanges, policy, policyTagList, policyCategories, false); - API.write(WRITE_COMMANDS.UPDATE_DISTANCE_REQUEST, params, onyxData); -} - const getConvertTrackedExpenseInformation = ( transactionID: string, actionableWhisperReportActionID: string, @@ -5175,7 +5149,14 @@ function setDraftSplitTransaction(transactionID: string, transactionChanges: Tra draftSplitTransaction = allTransactions[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]; } - const updatedTransaction = draftSplitTransaction ? TransactionUtils.getUpdatedTransaction(draftSplitTransaction, transactionChanges, false, false) : null; + const updatedTransaction = draftSplitTransaction + ? TransactionUtils.getUpdatedTransaction({ + transaction: draftSplitTransaction, + transactionChanges, + isFromExpenseReport: false, + shouldUpdateReceiptState: false, + }) + : null; Onyx.merge(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transactionID}`, updatedTransaction); } @@ -5339,296 +5320,6 @@ function createDistanceRequest( Report.notifyNewAction(activeReportID, userAccountID); } -function editRegularMoneyRequest( - transactionID: string, - transactionThreadReportID: string, - transactionChanges: TransactionChanges, - policy: OnyxTypes.Policy, - policyTags: OnyxTypes.PolicyTagLists, - policyCategories: OnyxTypes.PolicyCategories, -) { - const allReports = ReportConnection.getAllReports(); - // STEP 1: Get all collections we're updating - const transactionThread = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`] ?? null; - const transaction = allTransactions[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]; - const iouReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${transactionThread?.parentReportID}`] ?? null; - const chatReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${iouReport?.chatReportID}`] ?? null; - const isFromExpenseReport = ReportUtils.isExpenseReport(iouReport); - - // STEP 2: Build new modified expense report action. - const updatedReportAction = ReportUtils.buildOptimisticModifiedExpenseReportAction(transactionThread, transaction, transactionChanges, isFromExpenseReport, policy); - const updatedTransaction = transaction ? TransactionUtils.getUpdatedTransaction(transaction, transactionChanges, isFromExpenseReport) : null; - - // STEP 3: Compute the IOU total and update the report preview message so LHN amount owed is correct - // Should only update if the transaction matches the currency of the report, else we wait for the update - // from the server with the currency conversion - let updatedMoneyRequestReport = {...iouReport}; - const updatedChatReport = {...chatReport}; - const diff = TransactionUtils.getAmount(transaction, true) - TransactionUtils.getAmount(updatedTransaction, true); - if (updatedTransaction?.currency === iouReport?.currency && updatedTransaction?.modifiedAmount && diff !== 0) { - if (ReportUtils.isExpenseReport(iouReport) && typeof updatedMoneyRequestReport.total === 'number') { - updatedMoneyRequestReport.total += diff; - } else { - updatedMoneyRequestReport = iouReport - ? IOUUtils.updateIOUOwnerAndTotal(iouReport, updatedReportAction.actorAccountID ?? -1, diff, TransactionUtils.getCurrency(transaction), false) - : {}; - } - - updatedMoneyRequestReport.cachedTotal = CurrencyUtils.convertToDisplayString(updatedMoneyRequestReport.total, updatedTransaction.currency); - - // Update the last message of the IOU report - const lastMessage = ReportUtils.getIOUReportActionMessage( - iouReport?.reportID ?? '-1', - CONST.IOU.REPORT_ACTION_TYPE.CREATE, - updatedMoneyRequestReport.total ?? 0, - '', - updatedTransaction.currency, - '', - false, - ); - updatedMoneyRequestReport.lastMessageText = ReportActionsUtils.getTextFromHtml(lastMessage.at(0)?.html); - updatedMoneyRequestReport.lastMessageHtml = lastMessage.at(0)?.html; - - // Update the last message of the chat report - const hasNonReimbursableTransactions = ReportUtils.hasNonReimbursableTransactions(iouReport?.reportID); - const messageText = Localize.translateLocal(hasNonReimbursableTransactions ? 'iou.payerSpentAmount' : 'iou.payerOwesAmount', { - payer: ReportUtils.getPersonalDetailsForAccountID(updatedMoneyRequestReport.managerID ?? -1).login ?? '', - amount: CurrencyUtils.convertToDisplayString(updatedMoneyRequestReport.total, updatedMoneyRequestReport.currency), - }); - updatedChatReport.lastMessageText = messageText; - updatedChatReport.lastMessageHtml = messageText; - } - - const isScanning = TransactionUtils.hasReceipt(updatedTransaction) && TransactionUtils.isReceiptBeingScanned(updatedTransaction); - - // STEP 4: Compose the optimistic data - const currentTime = DateUtils.getDBTime(); - const optimisticData: OnyxUpdate[] = [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThread?.reportID}`, - value: { - [updatedReportAction.reportActionID]: updatedReportAction as OnyxTypes.ReportAction, - }, - }, - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, - value: updatedTransaction, - }, - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport?.reportID}`, - value: updatedMoneyRequestReport, - }, - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport?.chatReportID}`, - value: updatedChatReport, - }, - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`, - value: { - lastReadTime: currentTime, - lastVisibleActionCreated: currentTime, - }, - }, - ]; - - if (!isScanning) { - optimisticData.push( - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport?.reportID}`, - value: { - [transactionThread?.parentReportActionID ?? '-1']: { - originalMessage: { - whisperedTo: [], - }, - }, - }, - }, - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport?.parentReportID}`, - value: { - [iouReport?.parentReportActionID ?? '-1']: { - originalMessage: { - whisperedTo: [], - }, - }, - }, - }, - ); - } - - // Update recently used categories if the category is changed - if ('category' in transactionChanges) { - const optimisticPolicyRecentlyUsedCategories = Category.buildOptimisticPolicyRecentlyUsedCategories(iouReport?.policyID, transactionChanges.category); - if (optimisticPolicyRecentlyUsedCategories.length) { - optimisticData.push({ - onyxMethod: Onyx.METHOD.SET, - key: `${ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_CATEGORIES}${iouReport?.policyID}`, - value: optimisticPolicyRecentlyUsedCategories, - }); - } - } - - // Update recently used currencies if the currency is changed - if ('currency' in transactionChanges) { - const optimisticRecentlyUsedCurrencies = Policy.buildOptimisticRecentlyUsedCurrencies(transactionChanges.currency); - if (optimisticRecentlyUsedCurrencies.length) { - optimisticData.push({ - onyxMethod: Onyx.METHOD.SET, - key: ONYXKEYS.RECENTLY_USED_CURRENCIES, - value: optimisticRecentlyUsedCurrencies, - }); - } - } - - // Update recently used categories if the tag is changed - if ('tag' in transactionChanges) { - const optimisticPolicyRecentlyUsedTags = Tag.buildOptimisticPolicyRecentlyUsedTags(iouReport?.policyID, transactionChanges.tag); - if (!isEmptyObject(optimisticPolicyRecentlyUsedTags)) { - optimisticData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_TAGS}${iouReport?.policyID}`, - value: optimisticPolicyRecentlyUsedTags, - }); - } - } - - const successData: OnyxUpdate[] = [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThread?.reportID}`, - value: { - [updatedReportAction.reportActionID]: {pendingAction: null}, - }, - }, - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, - value: { - pendingFields: { - comment: null, - amount: null, - created: null, - currency: null, - merchant: null, - billable: null, - category: null, - tag: null, - }, - }, - }, - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport?.reportID}`, - value: {pendingAction: null}, - }, - ]; - - const failureData: OnyxUpdate[] = [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThread?.reportID}`, - value: { - [updatedReportAction.reportActionID]: { - errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('iou.error.genericEditFailureMessage'), - }, - }, - }, - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, - value: { - ...transaction, - modifiedCreated: transaction?.modifiedCreated ? transaction.modifiedCreated : null, - modifiedAmount: transaction?.modifiedAmount ? transaction.modifiedAmount : null, - modifiedCurrency: transaction?.modifiedCurrency ? transaction.modifiedCurrency : null, - modifiedMerchant: transaction?.modifiedMerchant ? transaction.modifiedMerchant : null, - modifiedWaypoints: transaction?.modifiedWaypoints ? transaction.modifiedWaypoints : null, - pendingFields: null, - }, - }, - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport?.reportID}`, - value: { - ...iouReport, - cachedTotal: iouReport?.cachedTotal ? iouReport?.cachedTotal : null, - }, - }, - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport?.chatReportID}`, - value: chatReport, - }, - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`, - value: { - lastReadTime: transactionThread?.lastReadTime, - lastVisibleActionCreated: transactionThread?.lastVisibleActionCreated, - }, - }, - ]; - - // Add transaction violations if we have a paid policy and an updated transaction - if (policy && PolicyUtils.isPaidGroupPolicy(policy) && updatedTransaction) { - const currentTransactionViolations = allTransactionViolations[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`] ?? []; - const updatedViolationsOnyxData = ViolationsUtils.getViolationsOnyxData( - updatedTransaction, - currentTransactionViolations, - policy, - policyTags, - policyCategories, - PolicyUtils.hasDependentTags(policy, policyTags), - ); - optimisticData.push(updatedViolationsOnyxData); - failureData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`, - value: currentTransactionViolations, - }); - } - - // STEP 6: Call the API endpoint - const {created, amount, currency, comment, merchant, category, billable, tag} = ReportUtils.getTransactionDetails(updatedTransaction) ?? {}; - - const parameters: EditMoneyRequestParams = { - transactionID, - reportActionID: updatedReportAction.reportActionID, - created, - amount, - currency, - comment, - merchant, - category, - billable, - tag, - }; - - API.write(WRITE_COMMANDS.EDIT_MONEY_REQUEST, parameters, {optimisticData, successData, failureData}); -} - -function editMoneyRequest( - transaction: OnyxTypes.Transaction, - transactionThreadReportID: string, - transactionChanges: TransactionChanges, - policy: OnyxTypes.Policy, - policyTags: OnyxTypes.PolicyTagLists, - policyCategories: OnyxTypes.PolicyCategories, -) { - if (TransactionUtils.isDistanceRequest(transaction)) { - updateDistanceRequest(transaction.transactionID, transactionThreadReportID, transactionChanges, policy, policyTags, policyCategories); - } else { - editRegularMoneyRequest(transaction.transactionID, transactionThreadReportID, transactionChanges, policy, policyTags, policyCategories); - } -} - type UpdateMoneyRequestAmountAndCurrencyParams = { transactionID: string; transactionThreadReportID: string; @@ -5664,9 +5355,9 @@ function updateMoneyRequestAmountAndCurrency({ const parentReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReport?.parentReportID}`] ?? null; let data: UpdateMoneyRequestData; if (ReportUtils.isTrackExpenseReport(transactionThreadReport) && ReportUtils.isSelfDM(parentReport)) { - data = getUpdateTrackExpenseParams(transactionID, transactionThreadReportID, transactionChanges, true, policy ?? null); + data = getUpdateTrackExpenseParams(transactionID, transactionThreadReportID, transactionChanges, policy); } else { - data = getUpdateMoneyRequestParams(transactionID, transactionThreadReportID, transactionChanges, policy, policyTagList ?? null, policyCategories ?? null, true); + data = getUpdateMoneyRequestParams(transactionID, transactionThreadReportID, transactionChanges, policy, policyTagList ?? null, policyCategories ?? null); } const {params, onyxData} = data; API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST_AMOUNT_AND_CURRENCY, params, onyxData); @@ -7167,10 +6858,9 @@ function isLastApprover(approvalChain: string[]): boolean { } function getNextApproverAccountID(report: OnyxEntry) { - const ownerAccountID = report?.ownerAccountID ?? -1; const policy = PolicyUtils.getPolicy(report?.policyID); - const approvalChain = ReportUtils.getApprovalChain(policy, ownerAccountID, report?.total ?? 0); - const submitToAccountID = PolicyUtils.getSubmitToAccountID(policy, ownerAccountID); + const approvalChain = ReportUtils.getApprovalChain(policy, report); + const submitToAccountID = PolicyUtils.getSubmitToAccountID(policy, report); if (approvalChain.length === 0) { return submitToAccountID; @@ -7198,7 +6888,7 @@ function approveMoneyRequest(expenseReport: OnyxEntry, full?: } const optimisticApprovedReportAction = ReportUtils.buildOptimisticApprovedReportAction(total, expenseReport?.currency ?? '', expenseReport?.reportID ?? '-1'); - const approvalChain = ReportUtils.getApprovalChain(PolicyUtils.getPolicy(expenseReport?.policyID), expenseReport?.ownerAccountID ?? -1, expenseReport?.total ?? 0); + const approvalChain = ReportUtils.getApprovalChain(PolicyUtils.getPolicy(expenseReport?.policyID), expenseReport); const predictedNextStatus = isLastApprover(approvalChain) ? CONST.REPORT.STATUS_NUM.APPROVED : CONST.REPORT.STATUS_NUM.SUBMITTED; const predictedNextState = isLastApprover(approvalChain) ? CONST.REPORT.STATE_NUM.APPROVED : CONST.REPORT.STATE_NUM.SUBMITTED; @@ -7558,7 +7248,7 @@ function submitReport(expenseReport: OnyxTypes.Report) { const parameters: SubmitReportParams = { reportID: expenseReport.reportID, - managerAccountID: PolicyUtils.getSubmitToAccountID(policy, expenseReport.ownerAccountID ?? -1) ?? expenseReport.managerID, + managerAccountID: PolicyUtils.getSubmitToAccountID(policy, expenseReport) ?? expenseReport.managerID, reportActionID: optimisticSubmittedReportAction.reportActionID, }; @@ -8136,6 +7826,9 @@ function putOnHold(transactionID: string, comment: string, reportID: string, sea }, {optimisticData, successData, failureData}, ); + + const currentReportID = ReportUtils.getDisplayedReportID(reportID); + Report.notifyNewAction(currentReportID, userAccountID); } /** @@ -8235,6 +7928,9 @@ function unholdRequest(transactionID: string, reportID: string, searchHash?: num }, {optimisticData, successData, failureData}, ); + + const currentReportID = ReportUtils.getDisplayedReportID(reportID); + Report.notifyNewAction(currentReportID, userAccountID); } // eslint-disable-next-line rulesdir/no-negated-variables function navigateToStartStepIfScanFileCannotBeRead( @@ -8594,7 +8290,6 @@ export { deleteTrackExpense, detachReceipt, dismissHoldUseExplanation, - editMoneyRequest, getIOURequestPolicyID, initMoneyRequest, navigateToStartStepIfScanFileCannotBeRead, diff --git a/src/libs/actions/OnyxUpdateManager/index.ts b/src/libs/actions/OnyxUpdateManager/index.ts index db00a55aa25b..085e05b0a449 100644 --- a/src/libs/actions/OnyxUpdateManager/index.ts +++ b/src/libs/actions/OnyxUpdateManager/index.ts @@ -1,11 +1,13 @@ -import type {OnyxEntry} from 'react-native-onyx'; +import type {OnyxEntry, OnyxUpdate} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; import * as ActiveClientManager from '@libs/ActiveClientManager'; import Log from '@libs/Log'; +import * as NetworkStore from '@libs/Network/NetworkStore'; import * as SequentialQueue from '@libs/Network/SequentialQueue'; import * as App from '@userActions/App'; +import updateSessionAuthTokens from '@userActions/Session/updateSessionAuthTokens'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {OnyxUpdatesFromServer} from '@src/types/onyx'; +import type {OnyxUpdatesFromServer, Session} from '@src/types/onyx'; import {isValidOnyxUpdateFromServer} from '@src/types/onyx/OnyxUpdatesFromServer'; import * as OnyxUpdateManagerUtils from './utils'; import * as DeferredOnyxUpdates from './utils/DeferredOnyxUpdates'; @@ -90,6 +92,10 @@ function handleOnyxUpdateGap(onyxUpdatesFromServer: OnyxEntry): void { + // Consolidate all of the given Onyx updates + const onyxUpdates: OnyxUpdate[] = []; + onyxUpdatesFromServer?.updates?.forEach((updateEvent) => onyxUpdates.push(...updateEvent.data)); + onyxUpdates.push(...(onyxUpdatesFromServer?.response?.onyxData ?? [])); + + // Find any session updates + const sessionUpdates = onyxUpdates?.filter((onyxUpdate) => onyxUpdate.key === ONYXKEYS.SESSION); + + // If any of the updates changes the authToken, let's update it now + sessionUpdates?.forEach((sessionUpdate) => { + const session = (sessionUpdate.value ?? {}) as Session; + const newAuthToken = session.authToken ?? ''; + if (!newAuthToken) { + return; + } + + Log.info('[OnyxUpdateManager] Found an authToken update while handling an Onyx update gap. Updating the authToken.'); + updateSessionAuthTokens(newAuthToken); + NetworkStore.setAuthToken(newAuthToken); + }); +} + export default () => { console.debug('[OnyxUpdateManager] Listening for updates from the server'); Onyx.connect({ diff --git a/src/libs/actions/Policy/PerDiem.ts b/src/libs/actions/Policy/PerDiem.ts index 2ce31fd4c921..1f6f0cf3dc9a 100644 --- a/src/libs/actions/Policy/PerDiem.ts +++ b/src/libs/actions/Policy/PerDiem.ts @@ -51,7 +51,7 @@ function generateCustomUnitID(): string { return NumberUtils.generateHexadecimalValue(13); } -function enablePerDiem(policyID: string, enabled: boolean, customUnitID?: string) { +function enablePerDiem(policyID: string, enabled: boolean, customUnitID?: string, disableRedirect?: boolean) { const doesCustomUnitExists = !!customUnitID; const finalCustomUnitID = doesCustomUnitExists ? customUnitID : generateCustomUnitID(); const optimisticCustomUnit = { @@ -104,7 +104,7 @@ function enablePerDiem(policyID: string, enabled: boolean, customUnitID?: string API.write(WRITE_COMMANDS.TOGGLE_POLICY_PER_DIEM, parameters, onyxData); - if (enabled && getIsNarrowLayout()) { + if (enabled && getIsNarrowLayout() && !disableRedirect) { navigateWhenEnableFeature(policyID); } } diff --git a/src/libs/actions/Policy/Policy.ts b/src/libs/actions/Policy/Policy.ts index 963770d65ccd..f514a9b27158 100644 --- a/src/libs/actions/Policy/Policy.ts +++ b/src/libs/actions/Policy/Policy.ts @@ -199,6 +199,12 @@ Onyx.connect({ callback: (val) => (allRecentlyUsedCurrencies = val ?? []), }); +let activePolicyID: OnyxEntry; +Onyx.connect({ + key: ONYXKEYS.NVP_ACTIVE_POLICY_ID, + callback: (value) => (activePolicyID = value), +}); + /** * Stores in Onyx the policy ID of the last workspace that was accessed by the user */ @@ -224,15 +230,6 @@ function getPolicy(policyID: string | undefined): OnyxEntry { return allPolicies[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]; } -/** - * Returns a primary policy for the user - */ -function getPrimaryPolicy(activePolicyID: OnyxEntry, currentUserLogin: string | undefined): Policy | undefined { - const activeAdminWorkspaces = PolicyUtils.getActiveAdminWorkspaces(allPolicies, currentUserLogin); - const primaryPolicy: Policy | null | undefined = activeAdminWorkspaces.find((policy) => policy.id === activePolicyID); - return primaryPolicy ?? activeAdminWorkspaces.at(0); -} - /** Check if the policy has invoicing company details */ function hasInvoicingDetails(policy: OnyxEntry): boolean { return !!policy?.invoice?.companyName && !!policy?.invoice?.companyWebsite; @@ -241,8 +238,8 @@ function hasInvoicingDetails(policy: OnyxEntry): boolean { /** * Returns a primary invoice workspace for the user */ -function getInvoicePrimaryWorkspace(activePolicyID: OnyxEntry, currentUserLogin: string | undefined): Policy | undefined { - if (PolicyUtils.canSendInvoiceFromWorkspace(activePolicyID)) { +function getInvoicePrimaryWorkspace(currentUserLogin: string | undefined): Policy | undefined { + if (PolicyUtils.canSendInvoiceFromWorkspace(activePolicyID ?? '-1')) { return allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${activePolicyID ?? '-1'}`]; } const activeAdminWorkspaces = PolicyUtils.getActiveAdminWorkspaces(allPolicies, currentUserLogin); @@ -1621,6 +1618,8 @@ function buildPolicyData(policyOwnerEmail = '', makeMeAdmin = false, policyName const optimisticCategoriesData = buildOptimisticPolicyCategories(policyID, CONST.POLICY.DEFAULT_CATEGORIES); + const shouldSetCreatedWorkspaceAsActivePolicy = !!activePolicyID && allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${activePolicyID}`]?.type === CONST.POLICY.TYPE.PERSONAL; + const optimisticData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.SET, @@ -1706,8 +1705,21 @@ function buildPolicyData(policyOwnerEmail = '', makeMeAdmin = false, policyName key: `${ONYXKEYS.COLLECTION.REPORT_DRAFT}${expenseChatReportID}`, value: null, }, + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT_DRAFT}${adminsChatReportID}`, + value: null, + }, ]; + if (shouldSetCreatedWorkspaceAsActivePolicy) { + optimisticData.push({ + onyxMethod: Onyx.METHOD.SET, + key: ONYXKEYS.NVP_ACTIVE_POLICY_ID, + value: policyID, + }); + } + const successData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, @@ -1796,6 +1808,14 @@ function buildPolicyData(policyOwnerEmail = '', makeMeAdmin = false, policyName }, ]; + if (shouldSetCreatedWorkspaceAsActivePolicy) { + failureData.push({ + onyxMethod: Onyx.METHOD.SET, + key: ONYXKEYS.NVP_ACTIVE_POLICY_ID, + value: activePolicyID ?? '', + }); + } + if (optimisticCategoriesData.optimisticData) { optimisticData.push(...optimisticCategoriesData.optimisticData); } @@ -2149,7 +2169,6 @@ function createWorkspaceFromIOUPayment(iouReport: OnyxEntry): WorkspaceF const policyID = generatePolicyID(); const workspaceName = generateDefaultWorkspaceName(sessionEmail); const employeeAccountID = iouReport.ownerAccountID; - const employeeEmail = iouReport.ownerEmail ?? ''; const {customUnits, customUnitID, customUnitRateID} = buildOptimisticDistanceRateCustomUnits(iouReport.currency); const oldPersonalPolicyID = iouReport.policyID; const iouReportID = iouReport.reportID; @@ -2169,6 +2188,8 @@ function createWorkspaceFromIOUPayment(iouReport: OnyxEntry): WorkspaceF return; } + const employeeEmail = allPersonalDetails?.[employeeAccountID]?.login ?? ''; + // Create the workspace chat for the employee whose IOU is being paid const employeeWorkspaceChat = createPolicyExpenseChats(policyID, {[employeeEmail]: employeeAccountID}, true); const newWorkspace = { @@ -2203,10 +2224,14 @@ function createWorkspaceFromIOUPayment(iouReport: OnyxEntry): WorkspaceF role: CONST.POLICY.ROLE.ADMIN, errors: {}, }, - [employeeEmail]: { - role: CONST.POLICY.ROLE.USER, - errors: {}, - }, + ...(employeeEmail + ? { + [employeeEmail]: { + role: CONST.POLICY.ROLE.USER, + errors: {}, + }, + } + : {}), }, pendingFields: { autoReporting: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, @@ -4592,7 +4617,6 @@ export { setPolicyCustomTaxName, clearPolicyErrorField, isCurrencySupportedForDirectReimbursement, - getPrimaryPolicy, getInvoicePrimaryWorkspace, createDraftWorkspace, savePreferredExportMethod, diff --git a/src/libs/actions/QueuedOnyxUpdates.ts b/src/libs/actions/QueuedOnyxUpdates.ts index 83bc6652cb39..bc19ff12aea1 100644 --- a/src/libs/actions/QueuedOnyxUpdates.ts +++ b/src/libs/actions/QueuedOnyxUpdates.ts @@ -19,4 +19,8 @@ function flushQueue(): Promise { }); } -export {queueOnyxUpdates, flushQueue}; +function isEmpty() { + return queuedOnyxUpdates.length === 0; +} + +export {queueOnyxUpdates, flushQueue, isEmpty}; diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index e3eb839fe1d8..adaab2cfaa79 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -994,7 +994,10 @@ function openReport( // eslint-disable-next-line rulesdir/no-multiple-api-calls API.paginate(CONST.API_REQUEST_TYPE.WRITE, WRITE_COMMANDS.OPEN_REPORT, parameters, {optimisticData, successData, failureData}, paginationConfig, { checkAndFixConflictingRequest: (persistedRequests) => - resolveDuplicationConflictAction(persistedRequests, (request) => request.command === WRITE_COMMANDS.OPEN_REPORT && request.data?.reportID === reportID), + resolveDuplicationConflictAction( + persistedRequests, + (request) => request.command === WRITE_COMMANDS.OPEN_REPORT && request.data?.reportID === reportID && request.data?.emailList === parameters.emailList, + ), }); } } diff --git a/src/libs/actions/Session/index.ts b/src/libs/actions/Session/index.ts index d75c5064f93a..eda761b9637b 100644 --- a/src/libs/actions/Session/index.ts +++ b/src/libs/actions/Session/index.ts @@ -41,6 +41,7 @@ import Timers from '@libs/Timers'; import {hideContextMenu} from '@pages/home/report/ContextMenu/ReportActionContextMenu'; import {KEYS_TO_PRESERVE, openApp} from '@userActions/App'; import * as App from '@userActions/App'; +import {KEYS_TO_PRESERVE_DELEGATE_ACCESS} from '@userActions/Delegate'; import * as Device from '@userActions/Device'; import * as PriorityMode from '@userActions/PriorityMode'; import redirectToSignIn from '@userActions/SignInRedirect'; @@ -483,13 +484,24 @@ function signUpUser() { function signInAfterTransitionFromOldDot(transitionURL: string) { const [route, queryParams] = transitionURL.split('?'); - const {email, authToken, encryptedAuthToken, accountID, autoGeneratedLogin, autoGeneratedPassword, clearOnyxOnStart, completedHybridAppOnboarding, isSingleNewDotEntry, primaryLogin} = - Object.fromEntries( - queryParams.split('&').map((param) => { - const [key, value] = param.split('='); - return [key, value]; - }), - ); + const { + email, + authToken, + encryptedAuthToken, + accountID, + autoGeneratedLogin, + autoGeneratedPassword, + clearOnyxOnStart, + completedHybridAppOnboarding, + isSingleNewDotEntry, + primaryLogin, + shouldRemoveDelegatedAccess, + } = Object.fromEntries( + queryParams.split('&').map((param) => { + const [key, value] = param.split('='); + return [key, value]; + }), + ); const clearOnyxForNewAccount = () => { if (clearOnyxOnStart !== 'true') { @@ -501,6 +513,12 @@ function signInAfterTransitionFromOldDot(transitionURL: string) { const setSessionDataAndOpenApp = new Promise((resolve) => { clearOnyxForNewAccount() + .then(() => { + if (!shouldRemoveDelegatedAccess) { + return; + } + return Onyx.clear(KEYS_TO_PRESERVE_DELEGATE_ACCESS); + }) .then(() => Onyx.multiSet({ [ONYXKEYS.SESSION]: {email, authToken, encryptedAuthToken: decodeURIComponent(encryptedAuthToken), accountID: Number(accountID)}, diff --git a/src/libs/actions/Transaction.ts b/src/libs/actions/Transaction.ts index e7a3465f1d25..3cb6e3dc44ba 100644 --- a/src/libs/actions/Transaction.ts +++ b/src/libs/actions/Transaction.ts @@ -323,6 +323,9 @@ function updateWaypoints(transactionID: string, waypoints: WaypointCollection, i return Onyx.merge(`${isDraft ? ONYXKEYS.COLLECTION.TRANSACTION_DRAFT : ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, { comment: { waypoints, + customUnit: { + quantity: null, + }, }, // We want to reset the amount only for draft transactions (when creating the expense). // When modifying an existing transaction, the amount will be updated on the actual IOU update operation. diff --git a/src/libs/actions/User.ts b/src/libs/actions/User.ts index eaccbb8497ac..f3b8b1a15c28 100644 --- a/src/libs/actions/User.ts +++ b/src/libs/actions/User.ts @@ -555,6 +555,16 @@ function validateLogin(accountID: number, validateCode: string) { Onyx.merge(ONYXKEYS.ACCOUNT, {...CONST.DEFAULT_ACCOUNT_DATA, isLoading: true}); const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.ACCOUNT, + value: { + isLoading: true, + }, + }, + ]; + + const finallyData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.ACCOUNT, @@ -566,7 +576,7 @@ function validateLogin(accountID: number, validateCode: string) { const parameters: ValidateLoginParams = {accountID, validateCode}; - API.write(WRITE_COMMANDS.VALIDATE_LOGIN, parameters, {optimisticData}); + API.write(WRITE_COMMANDS.VALIDATE_LOGIN, parameters, {optimisticData, finallyData}); Navigation.navigate(ROUTES.HOME); } diff --git a/src/libs/actions/Welcome/OnboardingFlow.ts b/src/libs/actions/Welcome/OnboardingFlow.ts index 9b7dfc894b6a..5a1b4fc0474a 100644 --- a/src/libs/actions/Welcome/OnboardingFlow.ts +++ b/src/libs/actions/Welcome/OnboardingFlow.ts @@ -57,7 +57,8 @@ function getOnboardingInitialPath(): string { if (isVsb) { Onyx.set(ONYXKEYS.ONBOARDING_PURPOSE_SELECTED, CONST.ONBOARDING_CHOICES.MANAGE_TEAM); - return `/${ROUTES.ONBOARDING_EMPLOYEES.route}`; + Onyx.set(ONYXKEYS.ONBOARDING_COMPANY_SIZE, CONST.ONBOARDING_COMPANY_SIZE.MICRO); + return `/${ROUTES.ONBOARDING_ACCOUNTING.route}`; } const isIndividual = onboardingValues.signupQualifier === CONST.ONBOARDING_SIGNUP_QUALIFIERS.INDIVIDUAL; if (isIndividual) { diff --git a/src/pages/EditReportFieldDropdown.tsx b/src/pages/EditReportFieldDropdown.tsx index a6bccdf3fa12..e8364d7d1f37 100644 --- a/src/pages/EditReportFieldDropdown.tsx +++ b/src/pages/EditReportFieldDropdown.tsx @@ -10,6 +10,7 @@ import useLocalize from '@hooks/useLocalize'; import useTheme from '@hooks/useTheme'; import localeCompare from '@libs/LocaleCompare'; import * as OptionsListUtils from '@libs/OptionsListUtils'; +import * as ReportFieldOptionsListUtils from '@libs/ReportFieldOptionsListUtils'; import ONYXKEYS from '@src/ONYXKEYS'; type EditReportFieldDropdownPageComponentProps = { @@ -58,7 +59,7 @@ function EditReportFieldDropdownPage({onSubmit, fieldKey, fieldValue, fieldOptio const [sections, headerMessage] = useMemo(() => { const validFieldOptions = fieldOptions?.filter((option) => !!option)?.sort(localeCompare); - const {policyReportFieldOptions} = OptionsListUtils.getFilteredOptions({ + const policyReportFieldOptions = ReportFieldOptionsListUtils.getReportFieldOptionsSection({ searchValue: debouncedSearchValue, selectedOptions: [ { @@ -67,21 +68,17 @@ function EditReportFieldDropdownPage({onSubmit, fieldKey, fieldValue, fieldOptio text: fieldValue, }, ], - - includeP2P: false, - canInviteUser: false, - includePolicyReportFieldOptions: true, - policyReportFieldOptions: validFieldOptions, - recentlyUsedPolicyReportFieldOptions: recentlyUsedOptions, + options: validFieldOptions, + recentlyUsedOptions, }); - const policyReportFieldData = policyReportFieldOptions?.[0]?.data ?? []; + const policyReportFieldData = policyReportFieldOptions.at(0)?.data ?? []; const header = OptionsListUtils.getHeaderMessageForNonUserList(policyReportFieldData.length > 0, debouncedSearchValue); return [policyReportFieldOptions, header]; }, [recentlyUsedOptions, debouncedSearchValue, fieldValue, fieldOptions]); - const selectedOptionKey = useMemo(() => (sections?.[0]?.data ?? []).filter((option) => option.searchText === fieldValue)?.at(0)?.keyForList, [sections, fieldValue]); + const selectedOptionKey = useMemo(() => (sections.at(0)?.data ?? []).filter((option) => option.searchText === fieldValue)?.at(0)?.keyForList, [sections, fieldValue]); return ( (undefined); const [error, setError] = useState(''); + const isVsb = onboardingValues && 'signupQualifier' in onboardingValues && onboardingValues.signupQualifier === CONST.ONBOARDING_SIGNUP_QUALIFIERS.VSB; + + // If the signupQualifier is VSB, the company size step is skip. + // So we need to create the new workspace in the accounting step + useEffect(() => { + if (!isVsb || !!onboardingPolicyID) { + return; + } + + const {adminsChatReportID, policyID} = Policy.createWorkspace(undefined, true, '', Policy.generatePolicyID(), CONST.ONBOARDING_CHOICES.MANAGE_TEAM); + Welcome.setOnboardingAdminsChatReportID(adminsChatReportID); + Welcome.setOnboardingPolicyID(policyID); + }, [isVsb, onboardingPolicyID]); const accountingOptions: OnboardingListItem[] = useMemo(() => { const policyAccountingOptions = Object.values(CONST.POLICY.CONNECTIONS.NAME) @@ -147,10 +163,11 @@ function BaseOnboardingAccounting({shouldUseNativeStyles, route}: BaseOnboarding onboardingCompanySize, userReportedIntegration, ); - - Welcome.setOnboardingAdminsChatReportID(); - Welcome.setOnboardingPolicyID(); - + // Avoid creating new WS because onboardingPolicyID is cleared before unmounting + InteractionManager.runAfterInteractions(() => { + Welcome.setOnboardingAdminsChatReportID(); + Welcome.setOnboardingPolicyID(); + }); navigateAfterOnboarding(isSmallScreenWidth, shouldUseNarrowLayout, canUseDefaultRooms, onboardingPolicyID, activeWorkspaceID, route.params?.backTo); }} pressOnEnter diff --git a/src/pages/ReportParticipantsPage.tsx b/src/pages/ReportParticipantsPage.tsx index 405e1dd688f2..1c91023f2d6f 100755 --- a/src/pages/ReportParticipantsPage.tsx +++ b/src/pages/ReportParticipantsPage.tsx @@ -91,7 +91,7 @@ function ReportParticipantsPage({report, route}: ReportParticipantsPageProps) { }); // Include the search bar when there are 8 or more active members in the selection list - const shouldShowTextInput = activeParticipants.length >= CONST.SHOULD_SHOW_MEMBERS_SEARCH_INPUT_BREAKPOINT; + const shouldShowTextInput = activeParticipants.length >= CONST.STANDARD_LIST_ITEM_LIMIT; useEffect(() => { if (!isFocused) { diff --git a/src/pages/RoomMembersPage.tsx b/src/pages/RoomMembersPage.tsx index 6a89eca6f778..37b96514cb63 100644 --- a/src/pages/RoomMembersPage.tsx +++ b/src/pages/RoomMembersPage.tsx @@ -183,7 +183,7 @@ function RoomMembersPage({report, policies}: RoomMembersPageProps) { // When offline, we want to include the pending members with delete action as they are displayed in the list as well return !pendingMember || isOffline || pendingMember.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; }); - return activeParticipants.length >= CONST.SHOULD_SHOW_MEMBERS_SEARCH_INPUT_BREAKPOINT; + return activeParticipants.length >= CONST.STANDARD_LIST_ITEM_LIMIT; }, [participants, personalDetails, isOffline, report]); useEffect(() => { diff --git a/src/pages/WorkspaceSwitcherPage/WorkspacesSectionHeader.tsx b/src/pages/WorkspaceSwitcherPage/WorkspacesSectionHeader.tsx index 9c714e54704c..89d052d263db 100644 --- a/src/pages/WorkspaceSwitcherPage/WorkspacesSectionHeader.tsx +++ b/src/pages/WorkspaceSwitcherPage/WorkspacesSectionHeader.tsx @@ -13,7 +13,12 @@ import Navigation from '@libs/Navigation/Navigation'; import * as App from '@userActions/App'; import CONST from '@src/CONST'; -function WorkspacesSectionHeader() { +type WorkspacesSectionHeaderProps = { + /** Should show the create workspace icon */ + shouldShowCreateWorkspaceIcon: boolean; +}; + +function WorkspacesSectionHeader({shouldShowCreateWorkspaceIcon}: WorkspacesSectionHeaderProps) { const theme = useTheme(); const styles = useThemeStyles(); const {translate} = useLocalize(); @@ -28,26 +33,28 @@ function WorkspacesSectionHeader() { {translate('common.workspaces')} - - { - const activeRoute = Navigation.getActiveRouteWithoutParams(); - interceptAnonymousUser(() => App.createWorkspaceWithPolicyDraftAndNavigateToIt('', '', false, false, activeRoute)); - }} - > - {({hovered}) => ( - - )} - - + {shouldShowCreateWorkspaceIcon && ( + + { + const activeRoute = Navigation.getActiveRouteWithoutParams(); + interceptAnonymousUser(() => App.createWorkspaceWithPolicyDraftAndNavigateToIt('', '', false, false, activeRoute)); + }} + > + {({hovered}) => ( + + )} + + + )} ); } diff --git a/src/pages/WorkspaceSwitcherPage/index.tsx b/src/pages/WorkspaceSwitcherPage/index.tsx index 221889b80b49..e418c5d5df59 100644 --- a/src/pages/WorkspaceSwitcherPage/index.tsx +++ b/src/pages/WorkspaceSwitcherPage/index.tsx @@ -183,12 +183,12 @@ function WorkspaceSwitcherPage() { pressableStyle={styles.flexRow} shouldSyncFocus={false} /> - + ListItem={UserListItem} sections={sections} onSelectRow={selectPolicy} - textInputLabel={usersWorkspaces.length >= CONST.WORKSPACE_SWITCHER.MINIMUM_WORKSPACES_TO_SHOW_SEARCH ? translate('common.search') : undefined} + textInputLabel={usersWorkspaces.length >= CONST.STANDARD_LIST_ITEM_LIMIT ? translate('common.search') : undefined} textInputValue={searchTerm} onChangeText={setSearchTerm} headerMessage={headerMessage} diff --git a/src/pages/home/ReportScreen.tsx b/src/pages/home/ReportScreen.tsx index 4c3ed5c705a5..62fd04f7572a 100644 --- a/src/pages/home/ReportScreen.tsx +++ b/src/pages/home/ReportScreen.tsx @@ -29,11 +29,9 @@ import usePrevious from '@hooks/usePrevious'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; import useViewportOffsetTop from '@hooks/useViewportOffsetTop'; -import Timing from '@libs/actions/Timing'; import Log from '@libs/Log'; import Navigation from '@libs/Navigation/Navigation'; import clearReportNotifications from '@libs/Notification/clearReportNotifications'; -import Performance from '@libs/Performance'; import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; import * as ReportUtils from '@libs/ReportUtils'; @@ -230,11 +228,7 @@ function ReportScreen({route, currentReportID = '', navigation}: ReportScreenPro const [scrollPosition, setScrollPosition] = useState({}); const wasReportAccessibleRef = useRef(false); - // eslint-disable-next-line react-compiler/react-compiler - if (firstRenderRef.current) { - Timing.start(CONST.TIMING.CHAT_RENDER); - Performance.markStart(CONST.TIMING.CHAT_RENDER); - } + const [isComposerFocus, setIsComposerFocus] = useState(false); const shouldAdjustScrollView = useMemo(() => isComposerFocus && !modal?.willAlertModalBecomeVisible, [isComposerFocus, modal]); const viewportOffsetTop = useViewportOffsetTop(shouldAdjustScrollView); @@ -487,9 +481,6 @@ function ReportScreen({route, currentReportID = '', navigation}: ReportScreenPro useAppFocusEvent(clearNotifications); useEffect(() => { - Timing.end(CONST.TIMING.CHAT_RENDER); - Performance.markEnd(CONST.TIMING.CHAT_RENDER); - const interactionTask = InteractionManager.runAfterInteractions(() => { ComposerActions.setShouldShowComposeInput(true); }); diff --git a/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx b/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx index 23b059f2fda2..4285916b593d 100644 --- a/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx +++ b/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx @@ -43,6 +43,7 @@ import ReportTypingIndicator from '@pages/home/report/ReportTypingIndicator'; import variables from '@styles/variables'; import * as EmojiPickerActions from '@userActions/EmojiPickerAction'; import * as Report from '@userActions/Report'; +import Timing from '@userActions/Timing'; import * as User from '@userActions/User'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -273,7 +274,8 @@ function ReportActionCompose({ Report.addAttachment(reportID, attachmentFileRef.current, newCommentTrimmed); attachmentFileRef.current = null; } else { - Performance.markStart(CONST.TIMING.MESSAGE_SENT, {message: newCommentTrimmed}); + Performance.markStart(CONST.TIMING.SEND_MESSAGE, {message: newCommentTrimmed}); + Timing.start(CONST.TIMING.SEND_MESSAGE); onSubmit(newCommentTrimmed); } }, diff --git a/src/pages/home/report/ReportActionItem.tsx b/src/pages/home/report/ReportActionItem.tsx index 2953036f6af7..559d635f73fe 100644 --- a/src/pages/home/report/ReportActionItem.tsx +++ b/src/pages/home/report/ReportActionItem.tsx @@ -52,6 +52,7 @@ import * as ReportActionsUtils from '@libs/ReportActionsUtils'; import * as ReportUtils from '@libs/ReportUtils'; import SelectionScraper from '@libs/SelectionScraper'; import shouldRenderAddPaymentCard from '@libs/shouldRenderAppPaymentCard'; +import {doesUserHavePaymentCardAdded} from '@libs/SubscriptionUtils'; import {ReactionListContext} from '@pages/home/ReportScreenContext'; import * as BankAccounts from '@userActions/BankAccounts'; import * as EmojiPickerAction from '@userActions/EmojiPickerAction'; @@ -397,7 +398,7 @@ function ReportActionItem({ const mentionReportContextValue = useMemo(() => ({currentReportID: report?.reportID ?? '-1'}), [report?.reportID]); const actionableItemButtons: ActionableItem[] = useMemo(() => { - if (ReportActionsUtils.isActionableAddPaymentCard(action) && shouldRenderAddPaymentCard()) { + if (ReportActionsUtils.isActionableAddPaymentCard(action) && !doesUserHavePaymentCardAdded() && shouldRenderAddPaymentCard()) { return [ { text: 'subscription.cardSection.addCardButton', diff --git a/src/pages/home/report/ReportActionItemThread.tsx b/src/pages/home/report/ReportActionItemThread.tsx index 94a8592d9607..13072a653749 100644 --- a/src/pages/home/report/ReportActionItemThread.tsx +++ b/src/pages/home/report/ReportActionItemThread.tsx @@ -7,6 +7,7 @@ import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import Timing from '@libs/actions/Timing'; +import Performance from '@libs/Performance'; import * as Report from '@userActions/Report'; import CONST from '@src/CONST'; import type {Icon} from '@src/types/onyx/OnyxCommon'; @@ -45,8 +46,9 @@ function ReportActionItemThread({numberOfReplies, icons, mostRecentReply, childR { - Report.navigateToAndOpenChildReport(childReportID); + Performance.markStart(CONST.TIMING.OPEN_REPORT_THREAD); Timing.start(CONST.TIMING.OPEN_REPORT_THREAD); + Report.navigateToAndOpenChildReport(childReportID); }} role={CONST.ROLE.BUTTON} accessibilityLabel={`${numberOfReplies} ${replyText}`} diff --git a/src/pages/home/report/ReportActionsView.tsx b/src/pages/home/report/ReportActionsView.tsx index 8896611905ca..31c0bc0fa752 100755 --- a/src/pages/home/report/ReportActionsView.tsx +++ b/src/pages/home/report/ReportActionsView.tsx @@ -6,7 +6,6 @@ import {InteractionManager} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import {useOnyx} from 'react-native-onyx'; import useCopySelectionHelper from '@hooks/useCopySelectionHelper'; -import useInitialValue from '@hooks/useInitialValue'; import useNetwork from '@hooks/useNetwork'; import usePrevious from '@hooks/usePrevious'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; @@ -285,7 +284,6 @@ function ReportActionsView({ const hasMoreCached = reportActions.length < combinedReportActions.length; const newestReportAction = useMemo(() => reportActions?.at(0), [reportActions]); const mostRecentIOUReportActionID = useMemo(() => ReportActionsUtils.getMostRecentIOURequestActionID(reportActions), [reportActions]); - const hasCachedActionOnFirstRender = useInitialValue(() => reportActions.length > 0); const hasNewestReportAction = reportActions.at(0)?.created === report.lastVisibleActionCreated || reportActions.at(0)?.created === transactionThreadReport?.lastVisibleActionCreated; const oldestReportAction = useMemo(() => reportActions?.at(-1), [reportActions]); @@ -427,18 +425,16 @@ function ReportActionsView({ } didLayout.current = true; - // Capture the init measurement only once not per each chat switch as the value gets overwritten - if (!ReportActionsView.initMeasured) { - Performance.markEnd(CONST.TIMING.OPEN_REPORT); - Performance.markEnd(CONST.TIMING.REPORT_INITIAL_RENDER); - ReportActionsView.initMeasured = true; - } else { - Performance.markEnd(CONST.TIMING.SWITCH_REPORT); - } - Timing.end(CONST.TIMING.SWITCH_REPORT, hasCachedActionOnFirstRender ? CONST.TIMING.WARM : CONST.TIMING.COLD); + + Performance.markEnd(CONST.TIMING.OPEN_REPORT); + Timing.end(CONST.TIMING.OPEN_REPORT); + + Performance.markEnd(CONST.TIMING.OPEN_REPORT_THREAD); Timing.end(CONST.TIMING.OPEN_REPORT_THREAD); + + Performance.markEnd(CONST.TIMING.OPEN_REPORT_FROM_PREVIEW); Timing.end(CONST.TIMING.OPEN_REPORT_FROM_PREVIEW); - }, [hasCachedActionOnFirstRender]); + }, []); // Check if the first report action in the list is the one we're currently linked to const isTheFirstReportActionIsLinked = newestReportAction?.reportActionID === reportActionID; @@ -501,7 +497,6 @@ function ReportActionsView({ } ReportActionsView.displayName = 'ReportActionsView'; -ReportActionsView.initMeasured = false; function arePropsEqual(oldProps: ReportActionsViewProps, newProps: ReportActionsViewProps): boolean { if (!lodashIsEqual(oldProps.reportActions, newProps.reportActions)) { diff --git a/src/pages/home/report/ReportAttachments.tsx b/src/pages/home/report/ReportAttachments.tsx index d30d8e9aabc1..871d692b59a3 100644 --- a/src/pages/home/report/ReportAttachments.tsx +++ b/src/pages/home/report/ReportAttachments.tsx @@ -20,6 +20,7 @@ function ReportAttachments({route}: ReportAttachmentsProps) { const isAuthTokenRequired = route.params.isAuthTokenRequired; const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID || -1}`); const [isLoadingApp] = useOnyx(ONYXKEYS.IS_LOADING_APP); + const fileName = route.params?.fileName; // In native the imported images sources are of type number. Ref: https://reactnative.dev/docs/image#imagesource const source = Number(route.params.source) || route.params.source; @@ -48,6 +49,7 @@ function ReportAttachments({route}: ReportAttachmentsProps) { onCarouselAttachmentChange={onCarouselAttachmentChange} shouldShowNotFoundPage={!isLoadingApp && type !== CONST.ATTACHMENT_TYPE.SEARCH && !report?.reportID} isAuthTokenRequired={!!isAuthTokenRequired} + originalFileName={fileName ?? ''} /> ); } diff --git a/src/pages/home/report/comment/TextCommentFragment.tsx b/src/pages/home/report/comment/TextCommentFragment.tsx index 530acc46233d..ab06a594a17f 100644 --- a/src/pages/home/report/comment/TextCommentFragment.tsx +++ b/src/pages/home/report/comment/TextCommentFragment.tsx @@ -14,6 +14,7 @@ import * as EmojiUtils from '@libs/EmojiUtils'; import Performance from '@libs/Performance'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; import variables from '@styles/variables'; +import Timing from '@userActions/Timing'; import CONST from '@src/CONST'; import type {OriginalMessageSource} from '@src/types/onyx/OriginalMessage'; import type {Message} from '@src/types/onyx/ReportAction'; @@ -52,7 +53,8 @@ function TextCommentFragment({fragment, styleAsDeleted, styleAsMuted = false, so const {shouldUseNarrowLayout} = useResponsiveLayout(); useEffect(() => { - Performance.markEnd(CONST.TIMING.MESSAGE_SENT, {message: text}); + Performance.markEnd(CONST.TIMING.SEND_MESSAGE, {message: text}); + Timing.end(CONST.TIMING.SEND_MESSAGE); }, [text]); // If the only difference between fragment.text and fragment.html is
tags and emoji tag diff --git a/src/pages/home/sidebar/SidebarLinks.tsx b/src/pages/home/sidebar/SidebarLinks.tsx index e62f2525e70b..e2df0ff6f33a 100644 --- a/src/pages/home/sidebar/SidebarLinks.tsx +++ b/src/pages/home/sidebar/SidebarLinks.tsx @@ -17,9 +17,6 @@ import ROUTES from '@src/ROUTES'; import type {Report} from '@src/types/onyx'; type SidebarLinksProps = { - /** Toggles the navigation menu open and closed */ - onLinkClick: () => void; - /** Safe area insets required for mobile devices margins */ insets: EdgeInsets; @@ -40,7 +37,7 @@ type SidebarLinksProps = { activeWorkspaceID: string | undefined; }; -function SidebarLinks({onLinkClick, insets, optionListItems, isLoading, priorityMode = CONST.PRIORITY_MODE.DEFAULT, isActiveReport}: SidebarLinksProps) { +function SidebarLinks({insets, optionListItems, isLoading, priorityMode = CONST.PRIORITY_MODE.DEFAULT, isActiveReport}: SidebarLinksProps) { const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const {updateLocale} = useLocalize(); @@ -75,9 +72,8 @@ function SidebarLinks({onLinkClick, insets, optionListItems, isLoading, priority return; } Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(option.reportID)); - onLinkClick(); }, - [shouldUseNarrowLayout, isActiveReport, onLinkClick], + [shouldUseNarrowLayout, isActiveReport], ); const viewMode = priorityMode === CONST.PRIORITY_MODE.GSD ? CONST.OPTION_MODE.COMPACT : CONST.OPTION_MODE.DEFAULT; diff --git a/src/pages/home/sidebar/SidebarLinksData.tsx b/src/pages/home/sidebar/SidebarLinksData.tsx index 7dfbdbaf7299..931bfd6c0d66 100644 --- a/src/pages/home/sidebar/SidebarLinksData.tsx +++ b/src/pages/home/sidebar/SidebarLinksData.tsx @@ -24,14 +24,11 @@ type SidebarLinksDataOnyxProps = { }; type SidebarLinksDataProps = SidebarLinksDataOnyxProps & { - /** Toggles the navigation menu open and closed */ - onLinkClick: () => void; - /** Safe area insets required for mobile devices margins */ insets: EdgeInsets; }; -function SidebarLinksData({insets, isLoadingApp = true, onLinkClick, priorityMode = CONST.PRIORITY_MODE.DEFAULT}: SidebarLinksDataProps) { +function SidebarLinksData({insets, isLoadingApp = true, priorityMode = CONST.PRIORITY_MODE.DEFAULT}: SidebarLinksDataProps) { const isFocused = useIsFocused(); const styles = useThemeStyles(); const activeWorkspaceID = useActiveWorkspaceFromNavigationState(); @@ -63,7 +60,6 @@ function SidebarLinksData({insets, isLoadingApp = true, onLinkClick, priorityMod > ({ initialValue: CONST.PRIORITY_MODE.DEFAULT, }, })( - /* + /* While working on audit on the App Start App metric we noticed that by memoizing SidebarLinksData we can avoid 2 additional run of getOrderedReportIDs. With that we can reduce app start up time by ~2s on heavy account. More details - https://github.com/Expensify/App/issues/35234#issuecomment-1926914534 */ memo( SidebarLinksData, - (prevProps, nextProps) => - prevProps.isLoadingApp === nextProps.isLoadingApp && - prevProps.priorityMode === nextProps.priorityMode && - lodashIsEqual(prevProps.insets, nextProps.insets) && - prevProps.onLinkClick === nextProps.onLinkClick, + (prevProps, nextProps) => prevProps.isLoadingApp === nextProps.isLoadingApp && prevProps.priorityMode === nextProps.priorityMode && lodashIsEqual(prevProps.insets, nextProps.insets), ), ); diff --git a/src/pages/home/sidebar/SidebarScreen/BaseSidebarScreen.tsx b/src/pages/home/sidebar/SidebarScreen/BaseSidebarScreen.tsx index e77f2000b85f..057189ae22c1 100644 --- a/src/pages/home/sidebar/SidebarScreen/BaseSidebarScreen.tsx +++ b/src/pages/home/sidebar/SidebarScreen/BaseSidebarScreen.tsx @@ -12,18 +12,9 @@ import TopBar from '@libs/Navigation/AppNavigator/createCustomBottomTabNavigator import Navigation from '@libs/Navigation/Navigation'; import Performance from '@libs/Performance'; import SidebarLinksData from '@pages/home/sidebar/SidebarLinksData'; -import Timing from '@userActions/Timing'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -/** - * Function called when a pinned chat is selected. - */ -const startTimer = () => { - Timing.start(CONST.TIMING.SWITCH_REPORT); - Performance.markStart(CONST.TIMING.SWITCH_REPORT); -}; - function BaseSidebarScreen() { const styles = useThemeStyles(); const activeWorkspaceID = useActiveWorkspaceFromNavigationState(); @@ -33,7 +24,6 @@ function BaseSidebarScreen() { useEffect(() => { Performance.markStart(CONST.TIMING.SIDEBAR_LOADED); - Timing.start(CONST.TIMING.SIDEBAR_LOADED); }, []); useEffect(() => { @@ -63,10 +53,7 @@ function BaseSidebarScreen() { shouldDisplaySearch={shouldDisplaySearch} /> - + )} diff --git a/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.tsx b/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.tsx index 88f0c389ba2e..247bdbf105c6 100644 --- a/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.tsx +++ b/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.tsx @@ -6,6 +6,7 @@ import {View} from 'react-native'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import {useOnyx} from 'react-native-onyx'; import type {SvgProps} from 'react-native-svg'; +import ConfirmModal from '@components/ConfirmModal'; import FloatingActionButton from '@components/FloatingActionButton'; import * as Expensicons from '@components/Icon/Expensicons'; import type {PopoverMenuItem} from '@components/PopoverMenu'; @@ -34,7 +35,6 @@ import variables from '@styles/variables'; import * as App from '@userActions/App'; import * as IOU from '@userActions/IOU'; import * as Link from '@userActions/Link'; -import * as Policy from '@userActions/Policy/Policy'; import * as Report from '@userActions/Report'; import * as Task from '@userActions/Task'; import * as Welcome from '@userActions/Welcome'; @@ -168,6 +168,7 @@ function FloatingActionButtonAndPopover({onHideCreateMenu, onShowCreateMenu}: Fl const [hasSeenTrackTraining] = useOnyx(ONYXKEYS.NVP_HAS_SEEN_TRACK_TRAINING); const [isCreateMenuActive, setIsCreateMenuActive] = useState(false); + const [modalVisible, setModalVisible] = useState(false); const fabRef = useRef(null); const {windowHeight} = useWindowDimensions(); const {shouldUseNarrowLayout} = useResponsiveLayout(); @@ -183,6 +184,22 @@ function FloatingActionButtonAndPopover({onHideCreateMenu, onShowCreateMenu}: Fl const [hasSeenTour = false] = useOnyx(ONYXKEYS.NVP_ONBOARDING, { selector: hasSeenTourSelector, }); + /** + * There are scenarios where users who have not yet had their group workspace-chats in NewDot (isPolicyExpenseChatEnabled). In those scenarios, things can get confusing if they try to submit/track expenses. To address this, we block them from Creating, Tracking, Submitting expenses from NewDot if they are: + * 1. on at least one group policy + * 2. none of the group policies they are a member of have isPolicyExpenseChatEnabled=true + */ + const shouldRedirectToExpensifyClassic = useMemo(() => { + const groupPolicies = Object.values(allPolicies ?? {}).filter((policy) => ReportUtils.isGroupPolicy(policy?.type ?? '')); + if (groupPolicies.length === 0) { + return false; + } + return !groupPolicies.some((policy) => !!policy?.isPolicyExpenseChatEnabled); + }, [allPolicies]); + + const shouldShowNewWorkspaceButton = Object.values(allPolicies ?? {}).every( + (policy) => !PolicyUtils.shouldShowPolicy(policy as OnyxEntry, !!isOffline, session?.email), + ); const quickActionAvatars = useMemo(() => { if (quickActionReport) { @@ -247,37 +264,27 @@ function FloatingActionButtonAndPopover({onHideCreateMenu, onShowCreateMenu}: Fl switch (quickAction?.action) { case CONST.QUICK_ACTIONS.REQUEST_MANUAL: - selectOption(() => IOU.startMoneyRequest(CONST.IOU.TYPE.SUBMIT, quickActionReportID, CONST.IOU.REQUEST_TYPE.MANUAL, true), true); - return; case CONST.QUICK_ACTIONS.REQUEST_SCAN: - selectOption(() => IOU.startMoneyRequest(CONST.IOU.TYPE.SUBMIT, quickActionReportID, CONST.IOU.REQUEST_TYPE.SCAN, true), true); - return; case CONST.QUICK_ACTIONS.REQUEST_DISTANCE: - selectOption(() => IOU.startMoneyRequest(CONST.IOU.TYPE.SUBMIT, quickActionReportID, CONST.IOU.REQUEST_TYPE.DISTANCE, true), true); + selectOption(() => IOU.startMoneyRequest(CONST.IOU.TYPE.SUBMIT, quickActionReportID, undefined, true), true); return; case CONST.QUICK_ACTIONS.SPLIT_MANUAL: - selectOption(() => IOU.startMoneyRequest(CONST.IOU.TYPE.SPLIT, quickActionReportID, CONST.IOU.REQUEST_TYPE.MANUAL, true), true); - return; case CONST.QUICK_ACTIONS.SPLIT_SCAN: - selectOption(() => IOU.startMoneyRequest(CONST.IOU.TYPE.SPLIT, quickActionReportID, CONST.IOU.REQUEST_TYPE.SCAN, true), true); + selectOption(() => IOU.startMoneyRequest(CONST.IOU.TYPE.SPLIT, quickActionReportID, undefined, true), true); return; case CONST.QUICK_ACTIONS.SPLIT_DISTANCE: - selectOption(() => IOU.startMoneyRequest(CONST.IOU.TYPE.SPLIT, quickActionReportID, CONST.IOU.REQUEST_TYPE.DISTANCE, false), true); + selectOption(() => IOU.startMoneyRequest(CONST.IOU.TYPE.SPLIT, quickActionReportID, undefined, false), true); return; case CONST.QUICK_ACTIONS.SEND_MONEY: - selectOption(() => IOU.startMoneyRequest(CONST.IOU.TYPE.PAY, quickActionReportID, CONST.IOU.REQUEST_TYPE.MANUAL, true), false); + selectOption(() => IOU.startMoneyRequest(CONST.IOU.TYPE.PAY, quickActionReportID, undefined, true), false); return; case CONST.QUICK_ACTIONS.ASSIGN_TASK: selectOption(() => Task.startOutCreateTaskQuickAction(isValidReport ? quickActionReportID : '', quickAction.targetAccountID ?? -1), false); break; case CONST.QUICK_ACTIONS.TRACK_MANUAL: - selectOption(() => IOU.startMoneyRequest(CONST.IOU.TYPE.TRACK, quickActionReportID, CONST.IOU.REQUEST_TYPE.MANUAL, true), false); - break; case CONST.QUICK_ACTIONS.TRACK_SCAN: - selectOption(() => IOU.startMoneyRequest(CONST.IOU.TYPE.TRACK, quickActionReportID, CONST.IOU.REQUEST_TYPE.SCAN, true), false); - break; case CONST.QUICK_ACTIONS.TRACK_DISTANCE: - selectOption(() => IOU.startMoneyRequest(CONST.IOU.TYPE.TRACK, quickActionReportID, CONST.IOU.REQUEST_TYPE.DISTANCE, true), false); + selectOption(() => IOU.startMoneyRequest(CONST.IOU.TYPE.TRACK, quickActionReportID, undefined, true), false); break; default: } @@ -350,7 +357,7 @@ function FloatingActionButtonAndPopover({onHideCreateMenu, onShowCreateMenu}: Fl }; // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps - const selfDMReportID = useMemo(() => ReportUtils.findSelfDMReportID(), [isLoading]); + const selfDMReportID = useMemo(() => ReportUtils.findSelfDMReportID(), [isLoading, isCreateMenuActive]); const expenseMenuItems = useMemo((): PopoverMenuItem[] => { if (canUseCombinedTrackSubmit) { @@ -358,15 +365,20 @@ function FloatingActionButtonAndPopover({onHideCreateMenu, onShowCreateMenu}: Fl { icon: getIconForAction(CONST.IOU.TYPE.CREATE), text: translate('iou.createExpense'), + shouldCallAfterModalHide: shouldRedirectToExpensifyClassic, onSelected: () => - interceptAnonymousUser(() => + interceptAnonymousUser(() => { + if (shouldRedirectToExpensifyClassic) { + setModalVisible(true); + return; + } IOU.startMoneyRequest( CONST.IOU.TYPE.CREATE, // When starting to create an expense from the global FAB, there is not an existing report yet. A random optimistic reportID is generated and used // for all of the routes in the creation flow. ReportUtils.generateReportID(), - ), - ), + ); + }), }, ]; } @@ -377,16 +389,21 @@ function FloatingActionButtonAndPopover({onHideCreateMenu, onShowCreateMenu}: Fl { icon: getIconForAction(CONST.IOU.TYPE.TRACK), text: translate('iou.trackExpense'), + shouldCallAfterModalHide: shouldRedirectToExpensifyClassic, onSelected: () => { - interceptAnonymousUser(() => + if (shouldRedirectToExpensifyClassic) { + setModalVisible(true); + return; + } + interceptAnonymousUser(() => { IOU.startMoneyRequest( CONST.IOU.TYPE.TRACK, // When starting to create a track expense from the global FAB, we need to retrieve selfDM reportID. // If it doesn't exist, we generate a random optimistic reportID and use it for all of the routes in the creation flow. // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing ReportUtils.findSelfDMReportID() || ReportUtils.generateReportID(), - ), - ); + ); + }); if (!hasSeenTrackTraining && !isOffline) { setTimeout(() => { Navigation.navigate(ROUTES.TRACK_TRAINING_MODAL); @@ -399,18 +416,24 @@ function FloatingActionButtonAndPopover({onHideCreateMenu, onShowCreateMenu}: Fl { icon: getIconForAction(CONST.IOU.TYPE.REQUEST), text: translate('iou.submitExpense'), + shouldCallAfterModalHide: shouldRedirectToExpensifyClassic, onSelected: () => - interceptAnonymousUser(() => + interceptAnonymousUser(() => { + if (shouldRedirectToExpensifyClassic) { + setModalVisible(true); + return; + } + IOU.startMoneyRequest( CONST.IOU.TYPE.SUBMIT, // When starting to create an expense from the global FAB, there is not an existing report yet. A random optimistic reportID is generated and used // for all of the routes in the creation flow. ReportUtils.generateReportID(), - ), - ), + ); + }), }, ]; - }, [canUseCombinedTrackSubmit, translate, selfDMReportID, hasSeenTrackTraining, isOffline]); + }, [canUseCombinedTrackSubmit, translate, selfDMReportID, hasSeenTrackTraining, isOffline, shouldRedirectToExpensifyClassic]); const quickActionMenuItems = useMemo(() => { // Define common properties in baseQuickAction @@ -503,15 +526,21 @@ function FloatingActionButtonAndPopover({onHideCreateMenu, onShowCreateMenu}: Fl { icon: Expensicons.InvoiceGeneric, text: translate('workspace.invoices.sendInvoice'), + shouldCallAfterModalHide: shouldRedirectToExpensifyClassic, onSelected: () => - interceptAnonymousUser(() => + interceptAnonymousUser(() => { + if (shouldRedirectToExpensifyClassic) { + setModalVisible(true); + return; + } + IOU.startMoneyRequest( CONST.IOU.TYPE.INVOICE, // When starting to create an invoice from the global FAB, there is not an existing report yet. A random optimistic reportID is generated and used // for all of the routes in the creation flow. ReportUtils.generateReportID(), - ), - ), + ); + }), }, ] : []), @@ -539,7 +568,7 @@ function FloatingActionButtonAndPopover({onHideCreateMenu, onShowCreateMenu}: Fl }, ] : []), - ...(!isLoading && !Policy.hasActiveChatEnabledPolicies(allPolicies) + ...(!isLoading && shouldShowNewWorkspaceButton ? [ { displayInDefaultIconColor: true, @@ -558,6 +587,18 @@ function FloatingActionButtonAndPopover({onHideCreateMenu, onShowCreateMenu}: Fl withoutOverlay anchorRef={fabRef} /> + { + setModalVisible(false); + Link.openOldDotLink(CONST.OLDDOT_URLS.INBOX); + }} + onCancel={() => setModalVisible(false)} + title={translate('sidebarScreen.redirectToExpensifyClassicModal.title')} + confirmText={translate('exitSurvey.goToExpensifyClassic')} + cancelText={translate('common.cancel')} + /> )} - {privateSubscription?.type === CONST.SUBSCRIPTION.TYPE.ANNUAL && } + {!!(privateSubscription?.type === CONST.SUBSCRIPTION.TYPE.ANNUAL && account?.hasPurchases) && } {!!account?.isEligibleForRefund && ( diff --git a/src/pages/settings/Wallet/VerifyAccountPage.tsx b/src/pages/settings/Wallet/VerifyAccountPage.tsx index 200b6b55363a..3bd3c2aa7000 100644 --- a/src/pages/settings/Wallet/VerifyAccountPage.tsx +++ b/src/pages/settings/Wallet/VerifyAccountPage.tsx @@ -21,6 +21,8 @@ function VerifyAccountPage({route}: VerifyAccountPageProps) { const loginData = loginList?.[contactMethod]; const validateLoginError = ErrorUtils.getEarliestErrorField(loginData, 'validateLogin'); const [isUserValidated] = useOnyx(ONYXKEYS.USER, {selector: (user) => !!user?.validated}); + const [accountID] = useOnyx(ONYXKEYS.SESSION, {selector: (session) => session?.accountID ?? 0}); + const [isValidateCodeActionModalVisible, setIsValidateCodeActionModalVisible] = useState(true); const navigateBackTo = route?.params?.backTo; @@ -28,10 +30,10 @@ function VerifyAccountPage({route}: VerifyAccountPageProps) { useEffect(() => () => User.clearUnvalidatedNewContactMethodAction(), []); const handleSubmitForm = useCallback( - (submitCode: string) => { - User.validateSecondaryLogin(loginList, contactMethod ?? '', submitCode); + (validateCode: string) => { + User.validateLogin(accountID ?? 0, validateCode); }, - [loginList, contactMethod], + [accountID], ); const clearError = useCallback(() => { diff --git a/src/pages/workspace/WorkspaceInitialPage.tsx b/src/pages/workspace/WorkspaceInitialPage.tsx index 971cc064f9a5..8d7acf236967 100644 --- a/src/pages/workspace/WorkspaceInitialPage.tsx +++ b/src/pages/workspace/WorkspaceInitialPage.tsx @@ -87,6 +87,8 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: Workspac const [isCurrencyModalOpen, setIsCurrencyModalOpen] = useState(false); const hasPolicyCreationError = !!(policy?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD && !isEmptyObject(policy.errors)); const [cardFeeds] = useOnyx(`${ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER}${workspaceAccountID}`); + const [cardSettings] = useOnyx(`${ONYXKEYS.COLLECTION.PRIVATE_EXPENSIFY_CARD_SETTINGS}${workspaceAccountID}`); + const [cardsList] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${workspaceAccountID}_${CONST.EXPENSIFY_CARD.BANK}`); const [connectionSyncProgress] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CONNECTION_SYNC_PROGRESS}${policy?.id}`); const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${route.params?.policyID ?? '-1'}`); const hasSyncError = PolicyUtils.hasSyncError(policy, isConnectionInProgress(connectionSyncProgress, policy)); @@ -213,6 +215,7 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: Workspac icon: Expensicons.ExpensifyCard, action: singleExecution(waitForNavigate(() => Navigation.navigate(ROUTES.WORKSPACE_EXPENSIFY_CARD.getRoute(policyID)))), routeName: SCREENS.WORKSPACE.EXPENSIFY_CARD, + brickRoadIndicator: !isEmptyObject(cardsList?.cardList?.errorFields ?? {}) || !isEmptyObject(cardSettings?.errors ?? {}) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined, }); } diff --git a/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldNew/NetSuiteCustomListSelectorModal.tsx b/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldNew/NetSuiteCustomListSelectorModal.tsx index e8f0d9e8315f..0f52f6a11d7c 100644 --- a/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldNew/NetSuiteCustomListSelectorModal.tsx +++ b/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldNew/NetSuiteCustomListSelectorModal.tsx @@ -61,7 +61,7 @@ function NetSuiteCustomListSelectorModal({isVisible, currentCustomListValue, onC }, ], headerMessage: isEmpty ? translate('common.noResultsFound') : '', - showTextInput: customListData.length > CONST.NETSUITE_CONFIG.NETSUITE_CUSTOM_LIST_LIMIT, + showTextInput: customListData.length > CONST.STANDARD_LIST_ITEM_LIMIT, }; }, [debouncedSearchValue, policy?.connections?.netsuite?.options?.data?.customLists, translate, currentCustomListValue]); diff --git a/src/pages/workspace/accounting/qbd/export/QuickbooksDesktopExportPage.tsx b/src/pages/workspace/accounting/qbd/export/QuickbooksDesktopExportPage.tsx index 8555be0d3d83..ff8547952155 100644 --- a/src/pages/workspace/accounting/qbd/export/QuickbooksDesktopExportPage.tsx +++ b/src/pages/workspace/accounting/qbd/export/QuickbooksDesktopExportPage.tsx @@ -31,7 +31,9 @@ function QuickbooksDesktopExportPage({policy}: WithPolicyConnectionsProps) { { description: translate('workspace.accounting.preferredExporter'), onPress: () => Navigation.navigate(ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_PREFERRED_EXPORTER.getRoute(policyID)), - title: qbdConfig?.export?.exporter ?? policyOwner, + // We use the logical OR (||) here instead of ?? because `exporter` could be an empty string + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + title: qbdConfig?.export?.exporter || policyOwner, subscribedSettings: [CONST.QUICKBOOKS_DESKTOP_CONFIG.EXPORTER], }, { diff --git a/src/pages/workspace/accounting/qbd/export/QuickbooksDesktopPreferredExporterConfigurationPage.tsx b/src/pages/workspace/accounting/qbd/export/QuickbooksDesktopPreferredExporterConfigurationPage.tsx index eef48ee04dcf..b571f67e8350 100644 --- a/src/pages/workspace/accounting/qbd/export/QuickbooksDesktopPreferredExporterConfigurationPage.tsx +++ b/src/pages/workspace/accounting/qbd/export/QuickbooksDesktopPreferredExporterConfigurationPage.tsx @@ -45,7 +45,9 @@ function QuickbooksDesktopPreferredExporterConfigurationPage({policy}: WithPolic value: exporter.email, text: exporter.email, keyForList: exporter.email, - isSelected: (currentExporter ?? policy?.owner) === exporter.email, + // We use the logical OR (||) here instead of ?? because `exporter` could be an empty string + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + isSelected: (currentExporter || policy?.owner) === exporter.email, }); return options; }, []), diff --git a/src/pages/workspace/companyCards/WorkspaceCompanyCardAccountSelectCardPage.tsx b/src/pages/workspace/companyCards/WorkspaceCompanyCardAccountSelectCardPage.tsx index c32cebd7ba18..a39a4401cb6e 100644 --- a/src/pages/workspace/companyCards/WorkspaceCompanyCardAccountSelectCardPage.tsx +++ b/src/pages/workspace/companyCards/WorkspaceCompanyCardAccountSelectCardPage.tsx @@ -63,7 +63,7 @@ function WorkspaceCompanyCardAccountSelectCardPage({route}: WorkspaceCompanyCard if (!exportMenuItem?.exportType) { return; } - CompanyCards.setCompanyCardExportAccount(workspaceAccountID, cardID, exportMenuItem.exportType, value, bank); + CompanyCards.setCompanyCardExportAccount(policyID, workspaceAccountID, cardID, exportMenuItem.exportType, value, bank); Navigation.goBack(ROUTES.WORKSPACE_COMPANY_CARD_DETAILS.getRoute(policyID, cardID, bank)); }, diff --git a/src/pages/workspace/companyCards/WorkspaceCompanyCardDetailsPage.tsx b/src/pages/workspace/companyCards/WorkspaceCompanyCardDetailsPage.tsx index 4bdf5211a2c0..9fdfa7bec7b3 100644 --- a/src/pages/workspace/companyCards/WorkspaceCompanyCardDetailsPage.tsx +++ b/src/pages/workspace/companyCards/WorkspaceCompanyCardDetailsPage.tsx @@ -130,12 +130,17 @@ function WorkspaceCompanyCardDetailsPage({route}: WorkspaceCompanyCardDetailsPag onPress={() => Navigation.navigate(ROUTES.WORKSPACE_COMPANY_CARD_NAME.getRoute(policyID, cardID, bank))} /> - {!!exportMenuItem && ( + {exportMenuItem?.shouldShowMenuItem ? ( CompanyCards.clearCompanyCardErrorField(workspaceAccountID, cardID, bank, 'exportAccountDetails')} + errors={exportMenuItem.exportType ? ErrorUtils.getLatestErrorField(card?.nameValuePairs ?? {}, exportMenuItem.exportType) : undefined} + onClose={() => { + if (!exportMenuItem.exportType) { + return; + } + CompanyCards.clearCompanyCardErrorField(workspaceAccountID, cardID, bank, exportMenuItem.exportType); + }} > Navigation.navigate(ROUTES.WORKSPACE_COMPANY_CARD_EXPORT.getRoute(policyID, cardID, bank))} /> - )} + ) : null} ) => { const cardID = Object.keys(cardsList ?? {}).find((id) => cardsList?.[id].cardID === item.cardID); const cardName = CardUtils.getCompanyCardNumber(cardsList?.cardList ?? {}, item.lastFourPAN); + const isCardDeleted = item.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; return ( { if (!cardID || !item?.accountID) { return; diff --git a/src/pages/workspace/companyCards/WorkspaceCompanyCardsListRow.tsx b/src/pages/workspace/companyCards/WorkspaceCompanyCardsListRow.tsx index 91eddfd96936..2ce8c289c96e 100644 --- a/src/pages/workspace/companyCards/WorkspaceCompanyCardsListRow.tsx +++ b/src/pages/workspace/companyCards/WorkspaceCompanyCardsListRow.tsx @@ -27,7 +27,7 @@ function WorkspaceCompanyCardsListRow({cardholder, name, cardNumber}: WorkspaceC ; @@ -38,8 +40,8 @@ function WorkspaceCompanyCardsSettingsFeedNamePage({ const {inputCallbackRef} = useAutoFocusInput(); const policy = usePolicy(policyID); const workspaceAccountID = policy?.workspaceAccountID ?? -1; - const [lastSelectedFeed] = useOnyx(`${ONYXKEYS.COLLECTION.LAST_SELECTED_FEED}${policyID}`); - const [cardFeeds] = useOnyx(`${ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER}${workspaceAccountID}`); + const [lastSelectedFeed, lastSelectedFeedResult] = useOnyx(`${ONYXKEYS.COLLECTION.LAST_SELECTED_FEED}${policyID}`); + const [cardFeeds, cardFeedsResult] = useOnyx(`${ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER}${workspaceAccountID}`); const selectedFeed = CardUtils.getSelectedFeed(lastSelectedFeed, cardFeeds); const feedName = cardFeeds?.settings?.companyCardNicknames?.[selectedFeed] ?? translate('workspace.companyCards.feedName', {feedName: CardUtils.getCardFeedName(selectedFeed)}); @@ -62,6 +64,10 @@ function WorkspaceCompanyCardsSettingsFeedNamePage({ Navigation.goBack(ROUTES.WORKSPACE_COMPANY_CARDS_SETTINGS.getRoute(policyID)); }; + if (isLoadingOnyxValue(cardFeedsResult) || isLoadingOnyxValue(lastSelectedFeedResult)) { + return ; + } + return ( void; data: SelectorType[]; - exportType?: string; + exportType?: ValueOf; + shouldShowMenuItem?: boolean; }; function getExportMenuItem( @@ -32,10 +34,11 @@ function getExportMenuItem( ): ExportIntegration | undefined { const currentConnectionName = PolicyUtils.getCurrentConnectionName(policy); - const {nonReimbursableExpensesExportDestination} = policy?.connections?.quickbooksOnline?.config ?? {}; + const {nonReimbursableExpensesExportDestination, nonReimbursableExpensesAccount} = policy?.connections?.quickbooksOnline?.config ?? {}; const {export: exportConfig} = policy?.connections?.intacct?.config ?? {}; const {export: exportConfiguration} = policy?.connections?.xero?.config ?? {}; const config = policy?.connections?.netsuite?.options.config; + const {bankAccounts} = policy?.connections?.xero?.data ?? {}; const {creditCards, bankAccounts: quickbooksOnlineBankAccounts} = policy?.connections?.quickbooksOnline?.data ?? {}; switch (connectionName) { @@ -43,20 +46,22 @@ function getExportMenuItem( const type = nonReimbursableExpensesExportDestination ? translate(`workspace.qbo.accounts.${nonReimbursableExpensesExportDestination}`) : undefined; const description = currentConnectionName && type ? translate('workspace.moreFeatures.companyCards.integrationExport', {integration: currentConnectionName, type}) : undefined; let data: Account[]; + let shouldShowMenuItem = true; let title: string | undefined = ''; - let exportType: string | undefined = ''; + let exportType: ValueOf | undefined; switch (nonReimbursableExpensesExportDestination) { case CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CREDIT_CARD: data = creditCards ?? []; - title = companyCard?.nameValuePairs?.exportAccountDetails?.quickbooks_desktop_export_account_credit; + title = companyCard?.nameValuePairs?.quickbooks_desktop_export_account_credit ?? nonReimbursableExpensesAccount?.name; exportType = CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_QUICKBOOKS_DESKTOP_EXPORT_ACCOUNT_CREDIT; break; case CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.DEBIT_CARD: data = quickbooksOnlineBankAccounts ?? []; - title = companyCard?.nameValuePairs?.exportAccountDetails?.quickbooks_online_export_account_debit; + title = companyCard?.nameValuePairs?.quickbooks_online_export_account_debit ?? nonReimbursableExpensesAccount?.name; exportType = CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_QUICKBOOKS_ONLINE_EXPORT_ACCOUNT_DEBIT; break; default: + shouldShowMenuItem = false; data = []; } @@ -64,6 +69,7 @@ function getExportMenuItem( description, title, exportType, + shouldShowMenuItem, onExportPagePress: () => Navigation.navigate(ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_EXPORT.getRoute(policyID)), data: data.map((card) => ({ value: card.name, @@ -77,10 +83,12 @@ function getExportMenuItem( const type = translate('workspace.xero.xeroBankAccount'); const description = currentConnectionName && type ? translate('workspace.moreFeatures.companyCards.integrationExport', {integration: currentConnectionName, type}) : undefined; const exportType = CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_XERO_EXPORT_BANK_ACCOUNT; + const selectedAccount = (bankAccounts ?? []).find((bank) => bank.id === exportConfiguration?.nonReimbursableAccount); return { description, exportType, - title: companyCard?.nameValuePairs?.exportAccountDetails?.xero_export_bank_account, + shouldShowMenuItem: true, + title: companyCard?.nameValuePairs?.xero_export_bank_account ?? selectedAccount?.name ?? bankAccounts?.[0]?.name ?? '', onExportPagePress: () => Navigation.navigate(ROUTES.POLICY_ACCOUNTING_XERO_EXPORT.getRoute(policyID)), data: getXeroBankAccounts(policy ?? undefined, exportConfiguration?.nonReimbursableAccount), }; @@ -90,27 +98,29 @@ function getExportMenuItem( ? translate(`workspace.netsuite.exportDestination.values.${config.nonreimbursableExpensesExportDestination}.label`) : undefined; let title: string | undefined = ''; - let exportType: string | undefined = ''; + let exportType: ValueOf | undefined; + let shouldShowMenuItem = true; const description = currentConnectionName && type ? translate('workspace.moreFeatures.companyCards.integrationExport', {integration: currentConnectionName, type}) : undefined; let data: SelectorType[]; switch (config?.nonreimbursableExpensesExportDestination) { case CONST.NETSUITE_EXPORT_DESTINATION.VENDOR_BILL: - title = companyCard?.nameValuePairs?.exportAccountDetails?.netsuite_export_vendor; data = getNetSuiteVendorOptions(policy ?? undefined, config?.defaultVendor); + title = companyCard?.nameValuePairs?.netsuite_export_vendor ?? data.find((exportVendor) => exportVendor.isSelected)?.text; exportType = CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_NETSUITE_EXPORT_VENDOR; break; case CONST.NETSUITE_EXPORT_DESTINATION.JOURNAL_ENTRY: - title = companyCard?.nameValuePairs?.exportAccountDetails?.netsuite_export_payable_account; data = getNetSuitePayableAccountOptions(policy ?? undefined, config?.payableAcct); + title = companyCard?.nameValuePairs?.netsuite_export_payable_account ?? data.find((exportPayable) => exportPayable.isSelected)?.text; exportType = CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_NETSUITE_EXPORT_ACCOUNT; - break; default: + shouldShowMenuItem = false; data = []; } return { description, title, + shouldShowMenuItem, exportType, data, onExportPagePress: () => Navigation.navigate(ROUTES.POLICY_ACCOUNTING_NETSUITE_EXPORT.getRoute(policyID)), @@ -121,13 +131,16 @@ function getExportMenuItem( const type = exportConfig?.nonReimbursable ? translate(`workspace.sageIntacct.nonReimbursableExpenses.values.${exportConfig.nonReimbursable}`) : undefined; const description = currentConnectionName && type ? translate('workspace.moreFeatures.companyCards.integrationExport', {integration: currentConnectionName, type}) : undefined; const activeDefaultVendor = getSageIntacctNonReimbursableActiveDefaultVendor(policy); + const data = isVendor ? getSageIntacctVendors(policy, activeDefaultVendor) : getSageIntacctCreditCards(policy, exportConfig?.nonReimbursableAccount); + const selectedAccount = data.find((account) => account.isSelected)?.text; return { description, + shouldShowMenuItem: true, exportType: isVendor ? CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_INTACCT_EXPORT_VENDOR : CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_INTACCT_EXPORT_CHARGE_CARD, - title: isVendor ? companyCard?.nameValuePairs?.exportAccountDetails?.intacct_export_vendor : companyCard?.nameValuePairs?.exportAccountDetails?.intacct_export_charge_card, + title: isVendor ? companyCard?.nameValuePairs?.intacct_export_vendor ?? selectedAccount : companyCard?.nameValuePairs?.intacct_export_charge_card ?? selectedAccount, onExportPagePress: () => Navigation.navigate(ROUTES.POLICY_ACCOUNTING_SAGE_INTACCT_EXPORT.getRoute(policyID)), - data: isVendor ? getSageIntacctVendors(policy, activeDefaultVendor) : getSageIntacctCreditCards(policy, exportConfig?.nonReimbursableAccount), + data, }; } default: diff --git a/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx b/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx index e079fdee90a0..3e72beee812b 100644 --- a/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx +++ b/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx @@ -219,7 +219,7 @@ function WorkspaceMemberDetailsPage({personalDetails, policy, route}: WorkspaceM return ; } - const shouldShowCardsSection = (!!policy?.areExpensifyCardsEnabled && !!paymentAccountID) ?? (!!policy?.areCompanyCardsEnabled && hasMultipleFeeds); + const shouldShowCardsSection = (!!policy?.areExpensifyCardsEnabled && !!paymentAccountID) || (!!policy?.areCompanyCardsEnabled && hasMultipleFeeds); return ( - {(memberCards as MemberCard[]).map((memberCard) => ( - navigateToDetails(memberCard)} - shouldShowRightIcon - /> - ))} + {(memberCards as MemberCard[]).map((memberCard) => { + const isCardDeleted = memberCard.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; + return ( + + navigateToDetails(memberCard)} + shouldRemoveHoverBackground={isCardDeleted} + disabled={isCardDeleted} + shouldShowRightIcon={!isCardDeleted} + style={[isCardDeleted ? styles.offlineFeedback.deleted : {}]} + /> + + ); + })} (undefined); const [allApprovers, setAllApprovers] = useState([]); - const shouldShowTextInput = allApprovers?.length >= 8; + const shouldShowTextInput = allApprovers?.length >= CONST.STANDARD_LIST_ITEM_LIMIT; // eslint-disable-next-line rulesdir/no-negated-variables const shouldShowNotFoundView = (isEmptyObject(policy) && !isLoadingReportData) || !PolicyUtils.isPolicyAdmin(policy) || PolicyUtils.isPendingDeletePolicy(policy); diff --git a/src/styles/utils/index.ts b/src/styles/utils/index.ts index 02778b4ca351..d196b3fdf09b 100644 --- a/src/styles/utils/index.ts +++ b/src/styles/utils/index.ts @@ -1119,20 +1119,25 @@ function getAmountWidth(amount: string): number { return width; } +/** + * When the item is selected and disabled, we want selected item styles. + * When the item is focused and disabled, we want disabled item styles. + * Single true value will give result accordingly. + */ function getItemBackgroundColorStyle(isSelected: boolean, isFocused: boolean, isDisabled: boolean, selectedBG: string, focusedBG: string): ViewStyle { - let backgroundColor; + if (isSelected) { + return {backgroundColor: selectedBG}; + } if (isDisabled) { - backgroundColor = undefined; - } else if (isSelected) { - backgroundColor = selectedBG; - } else if (isFocused) { - backgroundColor = focusedBG; + return {backgroundColor: undefined}; } - return { - backgroundColor, - }; + if (isFocused) { + return {backgroundColor: focusedBG}; + } + + return {}; } const staticStyleUtils = { diff --git a/src/types/modules/react-native.d.ts b/src/types/modules/react-native.d.ts index 40c5b72ce1e4..a08e17c28f4a 100644 --- a/src/types/modules/react-native.d.ts +++ b/src/types/modules/react-native.d.ts @@ -8,6 +8,7 @@ import type StartupTimer from '@libs/StartupTimer/types'; type HybridAppModule = { closeReactNativeApp: (shouldSignOut: boolean, shouldSetNVP: boolean) => void; completeOnboarding: (status: boolean) => void; + switchAccount: (newDotCurrentAccount: string) => void; exitApp: () => void; }; diff --git a/src/types/onyx/Card.ts b/src/types/onyx/Card.ts index c1d0e09d9312..8894db2723d1 100644 --- a/src/types/onyx/Card.ts +++ b/src/types/onyx/Card.ts @@ -2,10 +2,6 @@ import type {ValueOf} from 'type-fest'; import type CONST from '@src/CONST'; import type * as OnyxCommon from './OnyxCommon'; -/** Type of export card */ -type ExportCompanyCard = { - [key in ValueOf | ValueOf]: string; -}; /** Model of Expensify card */ type Card = OnyxCommon.OnyxValueWithOfflineFeedback<{ /** Card ID number */ @@ -76,9 +72,6 @@ type Card = OnyxCommon.OnyxValueWithOfflineFeedback<{ /** Type of card spending limits */ limitType?: ValueOf; - /** Type of export card */ - exportAccountDetails?: ExportCompanyCard; - /** User-defined nickname for the card */ cardTitle?: string; @@ -114,7 +107,11 @@ type Card = OnyxCommon.OnyxValueWithOfflineFeedback<{ /** Collection of form field errors */ errorFields?: OnyxCommon.ErrorFields; - }>; + }> & + OnyxCommon.OnyxValueWithOfflineFeedback<{ + /** Type of export card */ + [key in ValueOf | ValueOf]: string; + }>; }>; /** Model of Expensify card details */ diff --git a/tests/actions/IOUTest.ts b/tests/actions/IOUTest.ts index c2005d221273..1c26390f7b09 100644 --- a/tests/actions/IOUTest.ts +++ b/tests/actions/IOUTest.ts @@ -1608,321 +1608,6 @@ describe('actions/IOU', () => { }); }); - describe('edit expense', () => { - const amount = 10000; - const comment = '💸💸💸💸'; - const merchant = 'NASDAQ'; - - afterEach(() => { - mockFetch?.resume?.(); - }); - - it('updates the IOU request and IOU report when offline', () => { - let thread: OptimisticChatReport; - let iouReport: OnyxEntry; - let iouAction: OnyxEntry>; - let transaction: OnyxEntry; - - mockFetch?.pause?.(); - IOU.requestMoney({reportID: ''}, amount, [], CONST.CURRENCY.USD, '', merchant, RORY_EMAIL, RORY_ACCOUNT_ID, {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID}, comment, {}); - return waitForBatchedUpdates() - .then(() => { - Onyx.set(ONYXKEYS.SESSION, {email: RORY_EMAIL, accountID: RORY_ACCOUNT_ID}); - return waitForBatchedUpdates(); - }) - .then( - () => - new Promise((resolve) => { - const connection = Onyx.connect({ - key: ONYXKEYS.COLLECTION.REPORT, - waitForCollectionCallback: true, - callback: (allReports) => { - Onyx.disconnect(connection); - iouReport = Object.values(allReports ?? {}).find((report) => report?.type === CONST.REPORT.TYPE.IOU); - - resolve(); - }, - }); - }), - ) - .then( - () => - new Promise((resolve) => { - const connection = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport?.reportID}`, - waitForCollectionCallback: false, - callback: (reportActionsForIOUReport) => { - Onyx.disconnect(connection); - - [iouAction] = Object.values(reportActionsForIOUReport ?? {}).filter( - (reportAction): reportAction is OnyxTypes.ReportAction => ReportActionsUtils.isMoneyRequestAction(reportAction), - ); - resolve(); - }, - }); - }), - ) - .then( - () => - new Promise((resolve) => { - const connection = Onyx.connect({ - key: ONYXKEYS.COLLECTION.TRANSACTION, - waitForCollectionCallback: true, - callback: (allTransactions) => { - Onyx.disconnect(connection); - - transaction = Object.values(allTransactions ?? {}).find((t) => !isEmptyObject(t)); - resolve(); - }, - }); - }), - ) - .then(() => { - thread = ReportUtils.buildTransactionThread(iouAction, iouReport) ?? null; - Onyx.set(`report_${thread?.reportID ?? '-1'}`, thread); - return waitForBatchedUpdates(); - }) - .then(() => { - if (transaction) { - IOU.editMoneyRequest( - transaction, - thread.reportID, - {amount: 20000, comment: 'Double the amount!'}, - { - id: '123', - role: 'user', - type: CONST.POLICY.TYPE.TEAM, - name: '', - owner: '', - outputCurrency: '', - isPolicyExpenseChatEnabled: false, - }, - {}, - {}, - ); - } - return waitForBatchedUpdates(); - }) - .then( - () => - new Promise((resolve) => { - const connection = Onyx.connect({ - key: ONYXKEYS.COLLECTION.TRANSACTION, - waitForCollectionCallback: true, - callback: (allTransactions) => { - Onyx.disconnect(connection); - - const updatedTransaction = Object.values(allTransactions ?? {}).find((t) => !isEmptyObject(t)); - expect(updatedTransaction?.modifiedAmount).toBe(20000); - expect(updatedTransaction?.comment).toMatchObject({comment: 'Double the amount!'}); - resolve(); - }, - }); - }), - ) - .then( - () => - new Promise((resolve) => { - const connection = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${thread.reportID}`, - waitForCollectionCallback: false, - callback: (allActions) => { - Onyx.disconnect(connection); - const updatedAction = Object.values(allActions ?? {}).find((reportAction) => !isEmptyObject(reportAction)); - expect(updatedAction?.actionName).toEqual('MODIFIEDEXPENSE'); - expect(updatedAction && ReportActionsUtils.getOriginalMessage(updatedAction)).toEqual( - expect.objectContaining({amount: 20000, newComment: 'Double the amount!', oldAmount: amount, oldComment: comment}), - ); - resolve(); - }, - }); - }), - ) - .then( - () => - new Promise((resolve) => { - const connection = Onyx.connect({ - key: ONYXKEYS.COLLECTION.REPORT, - waitForCollectionCallback: true, - callback: (allReports) => { - Onyx.disconnect(connection); - const updatedIOUReport = Object.values(allReports ?? {}).find((report) => report?.type === CONST.REPORT.TYPE.IOU); - const updatedChatReport = Object.values(allReports ?? {}).find((report) => report?.reportID === iouReport?.chatReportID); - expect(updatedIOUReport).toEqual( - expect.objectContaining({ - total: 20000, - cachedTotal: '$200.00', - lastMessageHtml: 'submitted $200.00', - lastMessageText: 'submitted $200.00', - }), - ); - expect(updatedChatReport).toEqual( - expect.objectContaining({ - lastMessageHtml: `${CARLOS_EMAIL} owes $200.00`, - lastMessageText: `${CARLOS_EMAIL} owes $200.00`, - }), - ); - resolve(); - }, - }); - }), - ) - .then(() => { - mockFetch?.resume?.(); - }); - }); - - it('resets the IOU request and IOU report when api returns an error', () => { - let thread: OptimisticChatReport; - let iouReport: OnyxEntry; - let iouAction: OnyxEntry>; - let transaction: OnyxEntry; - - IOU.requestMoney({reportID: ''}, amount, [], CONST.CURRENCY.USD, '', merchant, RORY_EMAIL, RORY_ACCOUNT_ID, {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID}, comment, {}); - return waitForBatchedUpdates() - .then(() => { - Onyx.set(ONYXKEYS.SESSION, {email: RORY_EMAIL, accountID: RORY_ACCOUNT_ID}); - return waitForBatchedUpdates(); - }) - .then( - () => - new Promise((resolve) => { - const connection = Onyx.connect({ - key: ONYXKEYS.COLLECTION.REPORT, - waitForCollectionCallback: true, - callback: (allReports) => { - Onyx.disconnect(connection); - [iouReport] = Object.values(allReports ?? {}).filter((report) => report?.type === CONST.REPORT.TYPE.IOU); - resolve(); - }, - }); - }), - ) - .then( - () => - new Promise((resolve) => { - const connection = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport?.reportID}`, - waitForCollectionCallback: false, - callback: (reportActionsForIOUReport) => { - Onyx.disconnect(connection); - - [iouAction] = Object.values(reportActionsForIOUReport ?? {}).filter( - (reportAction): reportAction is OnyxTypes.ReportAction => ReportActionsUtils.isMoneyRequestAction(reportAction), - ); - resolve(); - }, - }); - }), - ) - .then( - () => - new Promise((resolve) => { - const connection = Onyx.connect({ - key: ONYXKEYS.COLLECTION.TRANSACTION, - waitForCollectionCallback: true, - callback: (allTransactions) => { - Onyx.disconnect(connection); - - transaction = Object.values(allTransactions ?? {}).find((t) => !isEmptyObject(t)); - resolve(); - }, - }); - }), - ) - .then(() => { - thread = ReportUtils.buildTransactionThread(iouAction, iouReport); - Onyx.set(`report_${thread.reportID}`, thread); - return waitForBatchedUpdates(); - }) - .then(() => { - mockFetch?.fail?.(); - - if (transaction) { - IOU.editMoneyRequest( - transaction, - thread.reportID, - {amount: 20000, comment: 'Double the amount!'}, - { - id: '123', - role: 'user', - type: CONST.POLICY.TYPE.TEAM, - name: '', - owner: '', - outputCurrency: '', - isPolicyExpenseChatEnabled: false, - }, - {}, - {}, - ); - } - return waitForBatchedUpdates(); - }) - .then( - () => - new Promise((resolve) => { - const connection = Onyx.connect({ - key: ONYXKEYS.COLLECTION.TRANSACTION, - waitForCollectionCallback: true, - callback: (allTransactions) => { - Onyx.disconnect(connection); - - const updatedTransaction = Object.values(allTransactions ?? {}).find((t) => !isEmptyObject(t)); - expect(updatedTransaction?.modifiedAmount).toBe(undefined); - expect(updatedTransaction?.amount).toBe(10000); - expect(updatedTransaction?.comment).toMatchObject({comment}); - resolve(); - }, - }); - }), - ) - .then( - () => - new Promise((resolve) => { - const connection = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${thread.reportID}`, - waitForCollectionCallback: false, - callback: (allActions) => { - Onyx.disconnect(connection); - const updatedAction = Object.values(allActions ?? {}).find((reportAction) => !isEmptyObject(reportAction)); - expect(updatedAction?.actionName).toEqual('MODIFIEDEXPENSE'); - expect(Object.values(updatedAction?.errors ?? {}).at(0)).toEqual(Localize.translateLocal('iou.error.genericEditFailureMessage')); - resolve(); - }, - }); - }), - ) - .then( - () => - new Promise((resolve) => { - const connection = Onyx.connect({ - key: ONYXKEYS.COLLECTION.REPORT, - waitForCollectionCallback: true, - callback: (allReports) => { - Onyx.disconnect(connection); - const updatedIOUReport = Object.values(allReports ?? {}).find((report) => report?.type === CONST.REPORT.TYPE.IOU); - const updatedChatReport = Object.values(allReports ?? {}).find((report) => report?.reportID === iouReport?.chatReportID); - expect(updatedIOUReport).toEqual( - expect.objectContaining({ - total: 10000, - cachedTotal: '$100.00', - lastMessageHtml: `submitted $${amount / 100}.00 for ${comment}`, - lastMessageText: `submitted $${amount / 100}.00 for ${comment}`, - }), - ); - expect(updatedChatReport).toEqual( - expect.objectContaining({ - lastMessageHtml: '', - }), - ); - resolve(); - }, - }); - }), - ); - }); - }); - describe('pay expense report via ACH', () => { const amount = 10000; const comment = '💸💸💸💸'; @@ -2596,11 +2281,14 @@ describe('actions/IOU', () => { jest.advanceTimersByTime(10); if (transaction && createIOUAction) { - IOU.editMoneyRequest( - transaction, - thread.reportID, - {amount: 20000, comment: 'Double the amount!'}, - { + IOU.updateMoneyRequestAmountAndCurrency({ + transactionID: transaction.transactionID, + transactionThreadReportID: thread.reportID, + amount: 20000, + currency: CONST.CURRENCY.USD, + taxAmount: 0, + taxCode: '', + policy: { id: '123', role: 'user', type: CONST.POLICY.TYPE.TEAM, @@ -2609,9 +2297,9 @@ describe('actions/IOU', () => { outputCurrency: '', isPolicyExpenseChatEnabled: false, }, - {}, - {}, - ); + policyTagList: {}, + policyCategories: {}, + }); } await waitForBatchedUpdates(); diff --git a/tests/actions/PolicyTest.ts b/tests/actions/PolicyTest.ts index 2ede9f5e5228..0bcd22e05bb7 100644 --- a/tests/actions/PolicyTest.ts +++ b/tests/actions/PolicyTest.ts @@ -6,6 +6,7 @@ import * as Policy from '@src/libs/actions/Policy/Policy'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Policy as PolicyType, Report, ReportAction, ReportActions} from '@src/types/onyx'; import type {Participant} from '@src/types/onyx/Report'; +import createRandomPolicy from '../utils/collections/policies'; import * as TestHelper from '../utils/TestHelper'; import type {MockFetch} from '../utils/TestHelper'; import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; @@ -33,6 +34,9 @@ describe('actions/Policy', () => { it('creates a new workspace', async () => { (fetch as MockFetch)?.pause?.(); Onyx.set(ONYXKEYS.SESSION, {email: ESH_EMAIL, accountID: ESH_ACCOUNT_ID}); + const fakePolicy = createRandomPolicy(0, CONST.POLICY.TYPE.PERSONAL); + Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${fakePolicy.id}`, fakePolicy); + Onyx.set(`${ONYXKEYS.NVP_ACTIVE_POLICY_ID}`, fakePolicy.id); await waitForBatchedUpdates(); let adminReportID; @@ -52,6 +56,19 @@ describe('actions/Policy', () => { }); }); + const activePolicyID: OnyxEntry = await new Promise((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.NVP_ACTIVE_POLICY_ID}`, + callback: (id) => { + Onyx.disconnect(connection); + resolve(id); + }, + }); + }); + + // check if NVP_ACTIVE_POLICY_ID is updated to created policy id + expect(activePolicyID).toBe(policyID); + // check if policy was created with correct values expect(policy?.id).toBe(policyID); expect(policy?.name).toBe(WORKSPACE_NAME); diff --git a/tests/unit/PolicyUtilsTest.ts b/tests/unit/PolicyUtilsTest.ts index e760bb2040c7..8178bb99e877 100644 --- a/tests/unit/PolicyUtilsTest.ts +++ b/tests/unit/PolicyUtilsTest.ts @@ -34,19 +34,14 @@ describe('PolicyUtils', () => { expect(rate).toEqual('10.50'); }); - it('should return non-integer value with 4 decimals as is', () => { - const rate = PolicyUtils.getRateDisplayValue(10.5312, toLocaleDigitMock, true); - expect(rate).toEqual('10.5312'); - }); - it('should return non-integer value with 3 decimals as is', () => { const rate = PolicyUtils.getRateDisplayValue(10.531, toLocaleDigitMock, true); expect(rate).toEqual('10.531'); }); - it('should return non-integer value with 4+ decimals cut to 4', () => { - const rate = PolicyUtils.getRateDisplayValue(10.531255, toLocaleDigitMock, true); - expect(rate).toEqual('10.5313'); + it('should return non-integer value with 3+ decimals cut to 3', () => { + const rate = PolicyUtils.getRateDisplayValue(10.531345, toLocaleDigitMock, true); + expect(rate).toEqual('10.531'); }); }); }); diff --git a/tests/unit/WorkspaceSettingsUtilsTest.json b/tests/unit/WorkspaceSettingsUtilsTest.json new file mode 100644 index 000000000000..ff83fe078adf --- /dev/null +++ b/tests/unit/WorkspaceSettingsUtilsTest.json @@ -0,0 +1,86 @@ +{ + "session": { + "accountID": 18634488 + }, + "reports": { + "report_4286515777714555": { + "type": "chat", + "isOwnPolicyExpenseChat": false, + "ownerAccountID": 0, + "parentReportActionID": "8722650843049927838", + "parentReportID": "6955627196303088", + "policyID": "57D0F454E0BCE54B", + "reportID": "4286515777714555", + "stateNum": 0, + "statusNum": 0 + }, + "report_6955627196303088": { + "reportID": "6955627196303088", + "chatReportID": "1699789757771388", + "policyID": "57D0F454E0BCE54B", + "type": "expense", + "ownerAccountID": 18634488, + "stateNum": 1, + "statusNum": 1, + "parentReportID": "1699789757771388", + "parentReportActionID": "7978085421707288417" + } + }, + "transactionViolations": { + "transactionViolations_3106135972713435169": [ + { + "name": "missingCategory", + "type": "violation" + } + ], + "transactionViolations_3690687111940510713": [ + { + "name": "missingCategory", + "type": "violation" + } + ] + }, + "reportActions": { + "reportActions_6955627196303088": { + "8722650843049927838": { + "actionName": "IOU", + "actorAccountID": 18634488, + "automatic": false, + "avatar": "https: //d2k5nsl2zxldvw.cloudfront.net/images/avatars/default-avatar_1.png", + "isAttachmentOnly": false, + "originalMessage": { + "amount": 12300, + "comment": "", + "currency": "VND", + "IOUTransactionID": "3106135972713435169", + "IOUReportID": "6955627196303088" + }, + "message": [ + { + "deleted": "", + "html": "₫123 expense", + "isDeletedParentAction": false, + "isEdited": false, + "text": "₫123 expense", + "type": "COMMENT", + "whisperedTo": [] + } + ], + "person": [ + { + "style": "strong", + "text": "adasdasd", + "type": "TEXT" + } + ], + "reportActionID": "8722650843049927838", + "shouldShow": true, + "created": "2024-11-05 11: 19: 18.706", + "childReportID": "4286515777714555", + "lastModified": "2024-11-05 11: 19: 18.706", + "childReportNotificationPreference": "hidden", + "childType": "chat" + } + } + } +} diff --git a/tests/unit/WorkspaceSettingsUtilsTest.ts b/tests/unit/WorkspaceSettingsUtilsTest.ts new file mode 100644 index 000000000000..9ee2b511379f --- /dev/null +++ b/tests/unit/WorkspaceSettingsUtilsTest.ts @@ -0,0 +1,69 @@ +import type {OnyxCollection} from 'react-native-onyx'; +import Onyx from 'react-native-onyx'; +import {getBrickRoadForPolicy} from '@libs/WorkspacesSettingsUtils'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Report, ReportActions, TransactionViolations} from '@src/types/onyx'; +import type {ReportCollectionDataSet} from '@src/types/onyx/Report'; +import * as TestHelper from '../utils/TestHelper'; +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; +import mockData from './WorkspaceSettingsUtilsTest.json'; + +describe('WorkspacesSettingsUtils', () => { + beforeAll(() => { + Onyx.init({ + keys: ONYXKEYS, + }); + }); + + beforeEach(() => { + global.fetch = TestHelper.getGlobalFetchMock(); + return Onyx.clear().then(waitForBatchedUpdates); + }); + describe('getBrickRoadForPolicy', () => { + it('Should return "error"', async () => { + // Given mock data for reports, transaction violations, sessions, and report actions. + const report = Object.values(mockData.reports)?.at(0); + const transactionViolations = mockData.transactionViolations; + const reports = mockData.reports; + const session = mockData.session; + const reportActions = mockData.reportActions; + + await Onyx.multiSet({ + ...(reports as ReportCollectionDataSet), + ...(reportActions as OnyxCollection), + ...(transactionViolations as OnyxCollection), + session, + }); + + await waitForBatchedUpdates(); + + // When calling getBrickRoadForPolicy with a report and report actions + const result = getBrickRoadForPolicy(report as Report, reportActions as OnyxCollection); + + // The result should be 'error' because there is at least one IOU action associated with a transaction that has a violation. + expect(result).toBe('error'); + }); + + it('Should return "undefined"', async () => { + // Given mock data for reports, sessions, and report actions. Note: Transaction data is intentionally excluded. + const report = Object.values(mockData.reports)?.at(0); + const reports = mockData.reports; + const session = mockData.session; + const reportActions = mockData.reportActions; + + await Onyx.multiSet({ + ...(reports as ReportCollectionDataSet), + ...(reportActions as OnyxCollection), + session, + }); + + await waitForBatchedUpdates(); + + // When calling getBrickRoadForPolicy with a report and report actions + const result = getBrickRoadForPolicy(report as Report, reportActions as OnyxCollection); + + // Then the result should be 'undefined' since no IOU action is linked to a transaction with a violation. + expect(result).toBe(undefined); + }); + }); +}); diff --git a/tests/utils/LHNTestUtils.tsx b/tests/utils/LHNTestUtils.tsx index ae9afd095d37..6fb767cfb3e3 100644 --- a/tests/utils/LHNTestUtils.tsx +++ b/tests/utils/LHNTestUtils.tsx @@ -251,7 +251,6 @@ function MockedSidebarLinks({currentReportID = ''}: MockedSidebarLinksProps) { * */} {}} insets={{ top: 0, left: 0, diff --git a/tests/utils/collections/policies.ts b/tests/utils/collections/policies.ts index 47bf996afb7e..bda1c3242997 100644 --- a/tests/utils/collections/policies.ts +++ b/tests/utils/collections/policies.ts @@ -3,11 +3,11 @@ import type {ValueOf} from 'type-fest'; import CONST from '@src/CONST'; import type {Policy} from '@src/types/onyx'; -export default function createRandomPolicy(index: number): Policy { +export default function createRandomPolicy(index: number, type?: ValueOf): Policy { return { id: index.toString(), name: randWord(), - type: rand(Object.values(CONST.POLICY.TYPE)), + type: type ?? rand(Object.values(CONST.POLICY.TYPE)), autoReporting: randBoolean(), isPolicyExpenseChatEnabled: randBoolean(), autoReportingFrequency: rand(