diff --git a/Mobile-Expensify b/Mobile-Expensify index a4663a30d7b9..84aeee54a184 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit a4663a30d7b948ea34c90f381773c60b8c3ebd0a +Subproject commit 84aeee54a184c5eb97bef6a707dde68a1b31d5ed diff --git a/__mocks__/expo-audio.ts b/__mocks__/expo-audio.ts new file mode 100644 index 000000000000..82168a790cee --- /dev/null +++ b/__mocks__/expo-audio.ts @@ -0,0 +1,18 @@ +const setAudioModeAsync = jest.fn(() => Promise.resolve()); + +const mockPlayer = { + play: jest.fn(), + pause: jest.fn(), + replace: jest.fn(), + remove: jest.fn(), + addListener: jest.fn(() => ({remove: jest.fn()})), +}; + +const createAudioPlayer = jest.fn(() => mockPlayer); + +const AudioModule = { + requestRecordingPermissionsAsync: jest.fn(() => Promise.resolve({granted: true})), +}; + +export {setAudioModeAsync, createAudioPlayer, AudioModule}; +export default {setAudioModeAsync, createAudioPlayer, AudioModule}; diff --git a/__mocks__/expo-av.ts b/__mocks__/expo-av.ts deleted file mode 100644 index 40a4a664af33..000000000000 --- a/__mocks__/expo-av.ts +++ /dev/null @@ -1,29 +0,0 @@ -import {View} from 'react-native'; - -const ResizeMode = { - CONTAIN: 'contain', - COVER: 'cover', - STRETCH: 'stretch', - CENTER: 'center', -}; - -const Video = class extends View { - setStatusAsync = jest.fn(() => Promise.resolve()); -}; - -const VideoFullscreenUpdate = { - PLAYER_WILL_PRESENT: 0, - PLAYER_DID_PRESENT: 1, - PLAYER_WILL_DISMISS: 2, - PLAYER_DID_DISMISS: 3, -}; - -const Audio = { - getPermissionsAsync: jest.fn(() => Promise.resolve({granted: true})), - requestPermissionsAsync: jest.fn(() => Promise.resolve()), - setAudioModeAsync: jest.fn(() => Promise.resolve()), - setIsEnabledAsync: jest.fn(() => Promise.resolve()), -}; - -export {ResizeMode, Audio, Video, VideoFullscreenUpdate}; -export default {ResizeMode, Audio, Video, VideoFullscreenUpdate}; diff --git a/config/webpack/webpack.common.ts b/config/webpack/webpack.common.ts index 5f90f9703a91..6009f24e131b 100644 --- a/config/webpack/webpack.common.ts +++ b/config/webpack/webpack.common.ts @@ -55,7 +55,7 @@ const includeModules = [ 'react-native-view-shot', '@react-native/assets', 'expo', - 'expo-av', + 'expo-audio', 'expo-video', 'expo-image-manipulator', 'expo-modules-core', diff --git a/contributingGuides/NAVIGATION.md b/contributingGuides/NAVIGATION.md index 026c122790e1..4eaf000dab35 100644 --- a/contributingGuides/NAVIGATION.md +++ b/contributingGuides/NAVIGATION.md @@ -511,6 +511,7 @@ function StepOne({isEditing, onNext, onMove, formValues}: CustomSubPageProps) { ```ts import useSubPage from '@hooks/useSubPage'; import type {SubPageProps} from '@hooks/useSubPage/types'; +import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; import InteractiveStepSubPageHeader from '@components/InteractiveStepSubPageHeader'; type CustomSubPageProps = SubPageProps & { @@ -533,6 +534,7 @@ function MyFlowContent() { lastPageIndex, moveTo, resetToPage, + isRedirecting, } = useSubPage({ pages, startFrom: 0, @@ -557,6 +559,10 @@ function MyFlowContent() { prevPage(); }; + if (isRedirecting) { + return ; + } + return ( + +
+
+
+
+ +
+ + + + + + + diff --git a/docs/_includes/sidebar-search.html b/docs/_includes/sidebar-search.html index 458680e30948..692ea2d1a0db 100644 --- a/docs/_includes/sidebar-search.html +++ b/docs/_includes/sidebar-search.html @@ -6,8 +6,15 @@

Search

- - -
+
+ + +
+
diff --git a/docs/_layouts/default.html b/docs/_layouts/default.html index d888cc244e46..de778ab1b720 100644 --- a/docs/_layouts/default.html +++ b/docs/_layouts/default.html @@ -17,7 +17,6 @@ - @@ -51,6 +50,7 @@ {% include sidebar-search.html id="sidebar-layer" %} + {% include search-result-item.html %}
diff --git a/docs/_sass/_search-bar.scss b/docs/_sass/_search-bar.scss index fcf81a1fbf05..7235e1de3152 100644 --- a/docs/_sass/_search-bar.scss +++ b/docs/_sass/_search-bar.scss @@ -4,10 +4,7 @@ .search-icon { margin: auto 0px; -} - -.gsc-input-box { - border: 0 !important; + margin-left: auto; } #sidebar-search { @@ -39,34 +36,30 @@ } .searchbar-title-wrapper { - padding: 20px; + display: flex; + align-items: center; + padding: 24px 20px; } .search-title { font-size: 17px; - padding-bottom: 20px; + margin: 0; + padding: 0; + line-height: 24px; } #toggle-search-close { - margin: auto; - margin-left: 0px; + display: flex; + align-items: center; margin-right: 10px; } /* Sidebar Layer */ #sidebar-layer { position: fixed; - - /* Sit on top of the page content */ display: none; - - /* Hidden by default */ width: 100%; - - /* Full width (cover the whole page) */ height: 100%; - - /* Full height (cover the whole page) */ top: 0; left: 0; right: 0; @@ -75,207 +68,145 @@ z-index: 1; } -/* All gsc id & class are Google Search relate gcse_0 is the search bar parent & gcse_1 is the search result list parent */ -#___gcse_0 { - margin-left: 20px; - margin-top: -8px; -} - -/* This input is in #___gcse_0 search bar */ -input#gsc-i-id1.gsc-input { - background-image: none !important; - background-color: var(--color-appBG) !important; - padding: 15px 0px 0px !important; - pointer-events: auto; - color: var(--color-text) !important; - font-family: 'Expensify Neue', 'Segoe UI Emoji', 'Noto Color Emoji' !important; +.search-form { + display: flex; + flex-direction: row; + align-items: center; + gap: 12px; + margin: 12px 20px 16px 20px; } -/* These below #gsc-iw-id1, .gsc-input-box & .gsib_a are inner wrapper of search bar input */ -#gsc-iw-id1 { - background-color: var(--color-appBG); - border-bottom: var(--color-borders) 2px solid !important; - border-bottom-left-radius: 0px; - pointer-events: none; +.search-input-wrapper { + position: relative; + display: flex; + flex-direction: column; + justify-content: flex-end; + border: 1px solid var(--color-borders); + border-radius: 8px; + padding: 7px 12px; + flex: 1; + height: 52px; + min-height: 52px; + box-sizing: border-box; + cursor: text; &:focus-within { - border-bottom: var(--color-accent) 2px solid !important; + border-color: var(--color-button-success-background); } } -.gsc-input-box .gsib_a { - padding: 0px 0px 4px 0px; -} - -.search-icon { - margin-left: auto; -} - -.gsst_b, -.gsst_a { - padding: 0px !important; -} -/* This is the close icon on search bar */ -.gsib_b .gsst_a .gscb_a { - color: var(--color-icons); - padding: 8px 6px 0px 6px !important; - pointer-events: auto; - - &:hover { - color: var(--color-text); - } -} - -/* This is to manage hover on parent close icon and make it the same effect on close icon */ -.gsst_a:hover { - .gscb_a { - color: var(--color-text) !important; - } -} - -/* Manage Google Search label animation */ -input#gsc-i-id1:focus + label.search-label, -input#gsc-i-id1:valid + label.search-label, -input#gsc-i-id1:active + label.search-label { - transform: translateY(-100%) scale(0.8); -} - -label.search-label { - display: block; - position: absolute; - margin-top: -20px; - font-size: 15px; +.search-label { font-family: 'Expensify Neue', 'Segoe UI Emoji', 'Noto Color Emoji'; - transform: translateY(-50%); - left: 20px; - pointer-events: none; color: var(--color-text-supporting); - transform-origin: left top; - user-select: none; - transition: transform 150ms cubic-bezier(0.4, 0, 0.2, 1), color 150ms cubic-bezier(0.4, 0, 0.2, 1), top 500ms; -} - -/* Hide the relevance, Ads, Branding, find more button & etc sections */ -.gsc-above-wrapper-area, -.gsc-webResult.gsc-result .gsc-url-top, -.gsc-results-wrapper-visible .gsc-adBlock, -.gcsc-more-maybe-branding-root, -.gcsc-find-more-on-google-root { - display: none; -} - -.gsc-control-cse { - background-color: var(--color-appBG) !important; - border: var(--color-appBG) !important; - font-family: 'Expensify Neue', 'Helvetica Neue', 'Helvetica', Arial, sans-serif !important; -} - -.gsc-webResult.gsc-result { - border-color: var(--color-appBG) !important; - background-color: var(--color-appBG) !important; + pointer-events: none; + font-size: 15px; + line-height: 20px; + transition: all 200ms ease-in-out; + position: absolute; + top: 50%; + left: 12px; + transform: translateY(-50%); } -/* Hide the scrollbar */ -.gsc-control-cse::-webkit-scrollbar { - display: none; +.search-input { + border: none; + outline: none; + background: transparent; + color: var(--color-text); + font-family: 'Expensify Neue', 'Segoe UI Emoji', 'Noto Color Emoji'; + font-size: 15px; + line-height: 20px; + width: 100%; + padding: 0; } -.gs-title * { - font-weight: bold; - color: var(--color-link) !important; +.search-input:focus ~ .search-label, +.search-input:valid ~ .search-label { + font-size: 13px; + line-height: 16px; + top: 7px; + transform: translateY(0); } -/* Change the Google Search Button icon into Expensify icon button */ -.gsc-search-button.gsc-search-button-v2 { - padding: 10px; - margin-left: 15px; - margin-right: 20px; - border-radius: 25px; +.search-button { background-color: var(--color-button-success-background); - border-color: var(--color-appBG) !important; - cursor: pointer; + border: none; + border-radius: 50%; width: 40px; height: 40px; -} - -.gsc-search-button.gsc-search-button-v2:hover, -.gsc-search-button.gsc-search-button-v2:focus { - background-color: var(--color-button-success-background-hover); -} + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + flex-shrink: 0; -.gsc-search-button.gsc-search-button-v2 svg { - fill: var(--color-button-text); - height: auto; - width: auto; -} + &:hover { + background-color: var(--color-button-success-background-hover); + } -.gsc-search-button.gsc-search-button-v2 svg path { - fill-rule: evenodd; - clip-rule: evenodd; + .base-icon { + filter: brightness(0) invert(1); + } } -.gsc-resultsbox-visible .gsc-webResult .gsc-result { - border-bottom: none; +.search-results { + display: flex; + flex-direction: column; + gap: 4px; + margin-top: 8px; } -/* Change Font the Google Search result */ -.gsc-control-cse .gsc-table-result { - font-family: 'Expensify Neue', 'Helvetica Neue', 'Helvetica', Arial, sans-serif !important; +.search-result-item { + display: flex; + flex-direction: row; + align-items: center; + gap: 12px; + padding: 12px 20px; + text-decoration: none; + cursor: pointer; + &:hover, + &:focus { + background-color: var(--color-highlightBG); + outline: none; + } } -/* Change Font result Paragraph color */ -.gsc-results .gs-webResult:not(.gs-no-results-result):not(.gs-error-result) .gs-snippet, -.gs-fileFormatType { - color: var(--color-text-supporting); +.search-result-content { + display: flex; + flex-direction: column; + gap: 4px; + flex: 1; + min-width: 0; } -/* Change the color of the Google Search Suggestion font */ -.gs-spelling.gs-result { +.search-result-title { + font-weight: 700; + font-size: 15px; + line-height: 20px; color: var(--color-text); } -/* Pagination related style */ -.gsc-resultsbox-visible .gsc-results .gsc-cursor-box { - text-align: center; -} - -.gsc-resultsbox-visible .gsc-results .gsc-cursor-box .gsc-cursor-page { - margin: 4px; - width: 28px; - height: 28px; - border-radius: 25px; - display: inline-block; - line-height: 2.5; - background-color: var(--color-accent); - font-weight: bold; - font-size: 11px; -} - -/* Change the color & background of Google Search Pagination */ -.gsc-cursor-next-page, -.gsc-cursor-final-page { - color: var(--color-text); - background-color: var(--color-appBG); +.search-result-description { + font-weight: 400; + font-size: 13px; + line-height: 16px; + color: var(--color-text-supporting); + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; } -/* Change the color & background of Google Search Current Page */ -.gsc-resultsbox-visible .gsc-results .gsc-cursor-box .gsc-cursor-page.gsc-cursor-current-page { - background-color: var(--color-accent); - color: var(--color-text); - - &:hover { - text-decoration: none; - background-color: var(--color-accent); - } +.search-result-item > .base-icon { + flex-shrink: 0; + width: 20px; + height: 20px; } -/* Change the color & background of Google Search of Other Page */ -.gsc-resultsbox-visible .gsc-results .gsc-cursor-box .gsc-cursor-page { - background-color: var(--color-button-background); - color: var(--color-text); - - &:hover { - background-color: var(--color-button-background-hover); - text-decoration: none; - } +.search-loading, +.search-no-results, +.search-error { + padding: 0 20px; + color: var(--color-text-supporting); + font-size: 15px; } diff --git a/docs/articles/new-expensify/connections/sage-intacct/Sage-Intacct-Troubleshooting.md b/docs/articles/new-expensify/connections/sage-intacct/Sage-Intacct-Troubleshooting.md deleted file mode 100644 index ae8a0f16d9b9..000000000000 --- a/docs/articles/new-expensify/connections/sage-intacct/Sage-Intacct-Troubleshooting.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -title: Sage Intacct Troubleshooting -description: Coming soon ---- - -# Coming soon diff --git a/docs/assets/js/main.js b/docs/assets/js/main.js index 1c896e0f21d5..9f429f6b59a3 100644 --- a/docs/assets/js/main.js +++ b/docs/assets/js/main.js @@ -101,7 +101,7 @@ function closeSidebarOnClickOutside(event) { function openSidebar() { document.getElementById('sidebar-layer').style.display = 'block'; - document.getElementById('gsc-i-id1').focus(); + document.getElementById('search-input').focus(); // Make body unscrollable const yAxis = document.documentElement.style.getPropertyValue('y-axis'); @@ -109,8 +109,6 @@ function openSidebar() { body.style.position = 'fixed'; body.style.top = `-${yAxis}`; - document.getElementById('gsc-i-id1').focus(); - // Close the sidebar when clicking sidebar layer (outside the sidebar search) const sidebarLayer = document.getElementById('sidebar-layer'); if (sidebarLayer) { @@ -118,52 +116,89 @@ function openSidebar() { } } -// Function to adapt & fix cropped SVG viewBox from Google based on viewport (Mobile or Tablet-Desktop) -function changeSVGViewBoxGoogle() { - // Get all inline Google SVG elements on the page - const svgsGoogle = document.querySelectorAll('svg[data-source]:not(.logo), .gsc-search-button.gsc-search-button-v2 svg'); +const SEARCH_API_URL = 'https://www.expensify.com/api/SearchHelpsite'; - Array.from(svgsGoogle).forEach((svg) => { - // Set the viewBox attribute to '0 0 13 13' to make the svg fit in the mobile view - svg.setAttribute('viewBox', '0 0 20 20'); - svg.setAttribute('height', '16'); - svg.setAttribute('width', '16'); - }); +function getTitleFromURL(url) { + return url.split('/').pop().replace(/-/g, ' '); } -// Function to insert element after another -// In this case, we insert the label element after the Google Search Input so we can have the same label animation effect -function insertElementAfter(referenceNode, newNode) { - referenceNode.parentNode.insertBefore(newNode, referenceNode.nextSibling); +/** + * Clone a template element by its ID. + * + * @param {string} templateId + * @returns {DocumentFragment} + */ +function cloneTemplate(templateId) { + return document.getElementById(templateId).content.cloneNode(true); } -// Update the ICON for search input. -/* Change the path of the Google Search Button icon into Expensify icon */ -function updateGoogleSearchIcon() { - const node = document.querySelector('.gsc-search-button.gsc-search-button-v2 svg path'); - node.setAttribute( - 'd', - 'M8 1c3.9 0 7 3.1 7 7 0 1.4-.4 2.7-1.1 3.8l5.2 5.2c.6.6.6 1.5 0 2.1-.6.6-1.5.6-2.1 0l-5.2-5.2C10.7 14.6 9.4 15 8 15c-3.9 0-7-3.1-7-7s3.1-7 7-7zm0 3c2.2 0 4 1.8 4 4s-1.8 4-4 4-4-1.8-4-4 1.8-4 4-4z', - ); -} +/** + * Search the help site using the SearchHelpsite API. + * + * @param {string} query + */ +function searchHelpsite(query) { + const resultsContainer = document.getElementById('search-results'); + if (!query.trim()) { + resultsContainer.innerHTML = ''; + return; + } -// Need to wait up until page is load, so the svg viewBox can be changed -// And the search label can be inserted -window.addEventListener('load', () => { - changeSVGViewBoxGoogle(); + resultsContainer.innerHTML = ''; + resultsContainer.appendChild(cloneTemplate('search-loading-template')); + + const isClassic = window.location.pathname.startsWith('/expensify-classic/'); + const formData = new FormData(); + formData.append('command', 'SearchHelpsite'); + formData.append('query', query.trim()); + if (isClassic) { + formData.append('platform', 'expensify-classic'); + } - updateGoogleSearchIcon(); + fetch(SEARCH_API_URL, { + method: 'POST', + body: formData, + }) + .then((response) => response.json()) + .then((data) => { + const results = (data.searchResults || []).filter((result) => !result.url.includes('/Unlisted/')); + resultsContainer.innerHTML = ''; + + if (results.length === 0) { + resultsContainer.appendChild(cloneTemplate('search-no-results-template')); + return; + } - // Add required into the search input - const searchInput = document.getElementById('gsc-i-id1'); - searchInput.setAttribute('required', ''); + results.forEach((result) => { + const item = cloneTemplate('search-result-item-template'); + const link = item.querySelector('.search-result-item'); + link.href = result.url; + link.querySelector('.search-result-title').textContent = getTitleFromURL(result.url); + const description = link.querySelector('.search-result-description'); + if (result.description) { + description.textContent = result.description; + } else { + description.remove(); + } + resultsContainer.appendChild(item); + }); + }) + .catch(() => { + resultsContainer.innerHTML = ''; + resultsContainer.appendChild(cloneTemplate('search-error-template')); + }); +} - // Insert search label after the search input - const searchLabel = document.createElement('label'); - searchLabel.classList.add('search-label'); - searchLabel.innerHTML = 'Search for something...'; - insertElementAfter(searchInput, searchLabel); -}); +function initSearch() { + const searchForm = document.getElementById('search-form'); + + if (searchForm) { + searchForm.addEventListener('submit', (e) => { + e.preventDefault(); + searchHelpsite(document.getElementById('search-input').value); + }); + } +} const FIXED_HEADER_HEIGHT = 80; @@ -256,6 +291,8 @@ window.addEventListener('DOMContentLoaded', () => { }); } + initSearch(); + document.getElementById('header-button').addEventListener('click', toggleHeaderMenu); // Back button doesn't exist on all the pages @@ -317,49 +354,3 @@ window.addEventListener('hashchange', () => { behavior: 'smooth', }); }); - -// We need to pass the results from readyCallback to renderedCallback so we make two part callback here to customize the results from GCSE API -const makeTwoPartCallback = () => { - let customResults = []; - const readyCallback = (name, q, promos, results, resultsDiv) => { - customResults = []; - results.forEach((result) => { - const {ogUrl, ogSiteName} = result.richSnippet.metatags; - - let newOgSiteName; - if (ogUrl.includes('expensify-classic')) { - newOgSiteName = 'Expensify Classic'; - } else if (ogUrl.includes('travel')) { - newOgSiteName = 'Expensify Travel'; - } else { - newOgSiteName = 'New Expensify'; - } - - result.title = result.title.replace(`- ${ogSiteName}`, `• ${newOgSiteName}`); - result.titleNoFormatting = result.titleNoFormatting.replace(`- ${ogSiteName}`, `• ${newOgSiteName}`); - if (!result.title.endsWith(` • ${newOgSiteName}`)) { - result.title = result.title + ` • ${newOgSiteName}`; - } - customResults.push(result); - }); - }; - const renderedCallback = (name, q, promos, results) => { - for (let i = 0; i < results.length; ++i) { - const div = results[i]; - const result = customResults[i]; - const titleElement = div.querySelector('a.gs-title'); - titleElement.innerHTML = result.title; - } - }; - return {readyCallback, renderedCallback}; -}; - -const {readyCallback: webResultsReadyCallback, renderedCallback: webResultsRenderedCallback} = makeTwoPartCallback(); - -window.__gcse || (window.__gcse = {}); -window.__gcse.searchCallbacks = { - web: { - ready: webResultsReadyCallback, - rendered: webResultsRenderedCallback, - }, -}; diff --git a/docs/redirects.csv b/docs/redirects.csv index 2e4e42182f9d..d02e1287e240 100644 --- a/docs/redirects.csv +++ b/docs/redirects.csv @@ -900,3 +900,4 @@ https://community.expensify.com/discussion/5700/deep-dive-approval-workflow-over https://community.expensify.com/discussion/5799/,https://help.expensify.com/articles/expensify-classic/spending-insights/Export-Expenses-And-Reports#date-formats https://community.expensify.com/discussion/5677/deep-dive-how-expensify-protects-your-information/,https://help.expensify.com/articles/new-expensify/settings/Encryption-and-Data-Security https://help.expensify.com/articles/new-expensify/reports-and-expenses/Split-Personal-Expenses,https://help.expensify.com/articles/new-expensify/reports-and-expenses/Create-an-Expense#split-personal-expenses +https://help.expensify.com/articles/new-expensify/connections/sage-intacct/Sage-Intacct-Troubleshooting,https://help.expensify.com/new-expensify/hubs/connections/sage-intacct diff --git a/ios/NewExpensify.xcodeproj/project.pbxproj b/ios/NewExpensify.xcodeproj/project.pbxproj index 9f605a676389..ce270adbe6e8 100644 --- a/ios/NewExpensify.xcodeproj/project.pbxproj +++ b/ios/NewExpensify.xcodeproj/project.pbxproj @@ -704,6 +704,7 @@ "${PODS_CONFIGURATION_BUILD_DIR}/AppAuth/AppAuthCore_Privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/EXConstants/EXConstants.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/EXConstants/ExpoConstants_privacy.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/EXTaskManager/ExpoTaskManager_privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/FirebaseABTesting/FirebaseABTesting_Privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/FirebaseCore/FirebaseCore_Privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/FirebaseCoreExtension/FirebaseCoreExtension_Privacy.bundle", @@ -738,6 +739,7 @@ "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/AppAuthCore_Privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/EXConstants.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoConstants_privacy.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoTaskManager_privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/FirebaseABTesting_Privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/FirebaseCore_Privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/FirebaseCoreExtension_Privacy.bundle", diff --git a/ios/Podfile.lock b/ios/Podfile.lock index a95a9176197b..ae6d9d487408 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -29,9 +29,6 @@ PODS: - AppLogs (0.1.0) - boost (1.84.0) - DoubleConversion (1.1.6) - - EXAV (15.1.7): - - ExpoModulesCore - - ReactCommon/turbomodule/core - EXConstants (18.0.10): - ExpoModulesCore - EXImageLoader (5.1.0): @@ -127,6 +124,8 @@ PODS: - Yoga - ExpoAsset (12.0.8): - ExpoModulesCore + - ExpoAudio (1.1.1): + - ExpoModulesCore - ExpoFont (14.0.8): - ExpoModulesCore - ExpoImage (3.0.8): @@ -3969,34 +3968,6 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - RNSound (0.13.0): - - boost - - DoubleConversion - - fast_float - - fmt - - glog - - hermes-engine - - RCT-Folly - - RCT-Folly/Fabric - - RCTRequired - - RCTTypeSafety - - React-Core - - React-debug - - React-Fabric - - React-featureflags - - React-graphics - - React-ImageManager - - React-jsi - - React-NativeModulesApple - - React-RCTFabric - - React-renderercss - - React-rendererdebug - - React-utils - - ReactCodegen - - ReactCommon/turbomodule/bridging - - ReactCommon/turbomodule/core - - SocketRocket - - Yoga - RNSVG (15.12.1): - boost - DoubleConversion @@ -4172,13 +4143,13 @@ DEPENDENCIES: - AppLogs (from `../node_modules/react-native-app-logs/AppLogsPod`) - boost (from `../node_modules/react-native/third-party-podspecs/boost.podspec`) - DoubleConversion (from `../node_modules/react-native/third-party-podspecs/DoubleConversion.podspec`) - - EXAV (from `../node_modules/expo-av/ios`) - EXConstants (from `../node_modules/expo-constants/ios`) - EXImageLoader (from `../node_modules/expo-image-loader/ios`) - "expensify-react-native-background-task (from `../node_modules/@expensify/react-native-background-task`)" - "ExpensifyNitroUtils (from `../node_modules/@expensify/nitro-utils`)" - Expo (from `../node_modules/expo`) - ExpoAsset (from `../node_modules/expo-asset/ios`) + - ExpoAudio (from `../node_modules/expo-audio/ios`) - ExpoFont (from `../node_modules/expo-font/ios`) - ExpoImage (from `../node_modules/expo-image/ios`) - ExpoImageManipulator (from `../node_modules/expo-image-manipulator/ios`) @@ -4310,7 +4281,6 @@ DEPENDENCIES: - RNScreens (from `../node_modules/react-native-screens`) - "RNSentry (from `../node_modules/@sentry/react-native`)" - RNShare (from `../node_modules/react-native-share`) - - RNSound (from `../node_modules/react-native-sound`) - RNSVG (from `../node_modules/react-native-svg`) - RNWorklets (from `../node_modules/react-native-worklets`) - SocketRocket (~> 0.7.1) @@ -4377,8 +4347,6 @@ EXTERNAL SOURCES: :podspec: "../node_modules/react-native/third-party-podspecs/boost.podspec" DoubleConversion: :podspec: "../node_modules/react-native/third-party-podspecs/DoubleConversion.podspec" - EXAV: - :path: "../node_modules/expo-av/ios" EXConstants: :path: "../node_modules/expo-constants/ios" EXImageLoader: @@ -4391,6 +4359,8 @@ EXTERNAL SOURCES: :path: "../node_modules/expo" ExpoAsset: :path: "../node_modules/expo-asset/ios" + ExpoAudio: + :path: "../node_modules/expo-audio/ios" ExpoFont: :path: "../node_modules/expo-font/ios" ExpoImage: @@ -4650,8 +4620,6 @@ EXTERNAL SOURCES: :path: "../node_modules/@sentry/react-native" RNShare: :path: "../node_modules/react-native-share" - RNSound: - :path: "../node_modules/react-native-sound" RNSVG: :path: "../node_modules/react-native-svg" RNWorklets: @@ -4675,13 +4643,13 @@ SPEC CHECKSUMS: AppLogs: 3bc4e9b141dbf265b9464409caaa40416a9ee0e0 boost: 659a89341ea4ab3df8259733813b52f26d8be9a5 DoubleConversion: cb417026b2400c8f53ae97020b2be961b59470cb - EXAV: ae28256069c4cdde93d185c007d8f68d92902c2e EXConstants: fd688cef4e401dcf798a021cfb5d87c890c30ba3 EXImageLoader: 4d3d3284141f1a45006cc4d0844061c182daf7ee expensify-react-native-background-task: 03c640e1f5649692d058cba48c0a138f024a6dd3 ExpensifyNitroUtils: 86109fe1ab88351ed63ffe11b760d537c70019d7 Expo: b2748512b0df06c1e569f93372b53c488f02ffe7 ExpoAsset: 84810d6fed8179f04d4a7a4a6b37028bbd726e26 + ExpoAudio: e4cfe3a2f3317b8487460685385a9867a07fb4fb ExpoFont: 86ceec09ffed1c99cfee36ceb79ba149074901b5 ExpoImage: e88f500585913969b930e13a4be47277eb7c6de8 ExpoImageManipulator: 7cb78b082bcc767b97867d833ce614ace424cd97 @@ -4848,7 +4816,6 @@ SPEC CHECKSUMS: RNScreens: 4f2aed147a2775017923789d8a0a2d377712ec2e RNSentry: 4832a7acd1fe48982149f585e1802f4d9fc7b00a RNShare: 1c1fde2c4134b9cf220ffebbd6df9c414036d382 - RNSound: 80f864773ff8f670fc8f052a3e478cec201d9b43 RNSVG: 94a1be05fab4043354bcf7104f0f9b0e2231ef05 RNWorklets: ab618bf7d1c7fd2cb793b9f0f39c3e29274b3ebf SDWebImage: 16309af6d214ba3f77a7c6f6fdda888cb313a50a diff --git a/jest/setup.ts b/jest/setup.ts index c8a42be1e724..baeab622dd21 100644 --- a/jest/setup.ts +++ b/jest/setup.ts @@ -88,14 +88,6 @@ jest.mock('react-native-fs', () => ({ CachesDirectoryPath: jest.fn(), })); -jest.mock('react-native-sound', () => { - class SoundMock { - play = jest.fn(); - } - - return SoundMock; -}); - jest.mock('react-native-share', () => ({ default: jest.fn(), })); diff --git a/package-lock.json b/package-lock.json index 00a5e0da8bc0..4d3b54f732df 100644 --- a/package-lock.json +++ b/package-lock.json @@ -67,7 +67,7 @@ "expensify-common": "2.0.169", "expo": "54.0.10", "expo-asset": "12.0.8", - "expo-av": "15.1.7", + "expo-audio": "1.1.1", "expo-font": "14.0.8", "expo-image": "3.0.8", "expo-image-manipulator": "^13.1.5", @@ -76,7 +76,7 @@ "expo-secure-store": "~14.2.4", "expo-store-review": "~9.0.8", "expo-task-manager": "~14.0.9", - "expo-video": "^3.0.12", + "expo-video": "3.0.12", "fast-equals": "^5.2.2", "focus-trap-react": "^11.0.3", "group-ib-fp": "file:modules/group-ib-fp", @@ -135,7 +135,6 @@ "react-native-safe-area-context": "5.6.2", "react-native-screens": "4.15.4", "react-native-share": "11.0.2", - "react-native-sound": "^0.13.0", "react-native-svg": "15.12.1", "react-native-tab-view": "^4.1.0", "react-native-url-polyfill": "^2.0.0", @@ -23599,21 +23598,16 @@ "react-native": "*" } }, - "node_modules/expo-av": { - "version": "15.1.7", - "resolved": "https://registry.npmjs.org/expo-av/-/expo-av-15.1.7.tgz", - "integrity": "sha512-NC+JR+65sxXfQN1mOHp3QBaXTL2J+BzNwVO27XgUEc5s9NaoBTdHWElYXrfxvik6xwytZ+a7abrqfNNgsbQzsA==", + "node_modules/expo-audio": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/expo-audio/-/expo-audio-1.1.1.tgz", + "integrity": "sha512-CPCpJ+0AEHdzWROc0f00Zh6e+irLSl2ALos/LPvxEeIcJw1APfBa4DuHPkL4CQCWsVe7EnUjFpdwpqsEUWcP0g==", "license": "MIT", "peerDependencies": { "expo": "*", + "expo-asset": "*", "react": "*", - "react-native": "*", - "react-native-web": "*" - }, - "peerDependenciesMeta": { - "react-native-web": { - "optional": true - } + "react-native": "*" } }, "node_modules/expo-constants": { @@ -34477,16 +34471,6 @@ "node": ">=16" } }, - "node_modules/react-native-sound": { - "version": "0.13.0", - "resolved": "https://registry.npmjs.org/react-native-sound/-/react-native-sound-0.13.0.tgz", - "integrity": "sha512-SnREzaV0fmpYNuDV1Y8M7FutmaYei0pKBgpldULKKJMkoA3DBv5ppyRxY+oxRQ7HwEpt6LsonrKgM+13GH/tCw==", - "license": "MIT", - "peerDependencies": { - "react": "*", - "react-native": "*" - } - }, "node_modules/react-native-svg": { "version": "15.12.1", "resolved": "https://registry.npmjs.org/react-native-svg/-/react-native-svg-15.12.1.tgz", diff --git a/package.json b/package.json index 1787569a60fe..be7c7f1840e5 100644 --- a/package.json +++ b/package.json @@ -136,16 +136,16 @@ "expensify-common": "2.0.169", "expo": "54.0.10", "expo-asset": "12.0.8", - "expo-av": "15.1.7", + "expo-audio": "1.1.1", "expo-font": "14.0.8", "expo-image": "3.0.8", "expo-image-manipulator": "^13.1.5", "expo-location": "^19.0.7", "expo-modules-core": "3.0.18", "expo-secure-store": "~14.2.4", - "expo-video": "^3.0.12", "expo-store-review": "~9.0.8", "expo-task-manager": "~14.0.9", + "expo-video": "3.0.12", "fast-equals": "^5.2.2", "focus-trap-react": "^11.0.3", "group-ib-fp": "file:modules/group-ib-fp", @@ -204,7 +204,6 @@ "react-native-safe-area-context": "5.6.2", "react-native-screens": "4.15.4", "react-native-share": "11.0.2", - "react-native-sound": "^0.13.0", "react-native-svg": "15.12.1", "react-native-tab-view": "^4.1.0", "react-native-url-polyfill": "^2.0.0", diff --git a/scripts/stubReactNative.js b/scripts/stubReactNative.js index 31780835739e..243443eb429f 100644 --- a/scripts/stubReactNative.js +++ b/scripts/stubReactNative.js @@ -9,7 +9,6 @@ const MODULES_TO_STUB = new Set([ 'react-native-config', 'react-native-key-command', '@expensify/react-native-hybrid-app', - 'react-native-sound', 'react-native-blob-util', 'react-native-fs', 'react-native-reanimated', @@ -19,7 +18,7 @@ const MODULES_TO_STUB = new Set([ 'react-native-picker-select', 'react-native-onyx', '@react-navigation/native', - 'expo-av', + 'expo-audio', ]); // Stub implementations diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 05aab0de6175..55c46f726777 100755 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -8072,6 +8072,7 @@ const CONST = { }, SEARCH: { SEARCH_BUTTON: 'Search-SearchButton', + USER_SELECTION_CHECKBOX: 'Search-UserSelectionCheckbox', }, REPORT: { FLOATING_MESSAGE_COUNTER: 'Report-FloatingMessageCounter', @@ -8099,6 +8100,7 @@ const CONST = { THREAD_DIVIDER: 'Report-ThreadDivider', PURE_REPORT_ACTION_ITEM: 'Report-PureReportActionItem', MODERATION_BUTTON: 'Report-ModerationButton', + MONEY_REQUEST_REPORT_ACTIONS_LIST_SELECT_ALL: 'MoneyRequestReportActionsList-SelectAll', }, SIDEBAR: { SIGN_IN_BUTTON: 'Sidebar-SignInButton', diff --git a/src/Expensify.tsx b/src/Expensify.tsx index be2d83ad55a1..8bc31b6926f8 100644 --- a/src/Expensify.tsx +++ b/src/Expensify.tsx @@ -1,6 +1,5 @@ import HybridAppModule from '@expensify/react-native-hybrid-app'; import * as Sentry from '@sentry/react-native'; -import {Audio} from 'expo-av'; import React, {useCallback, useEffect, useLayoutEffect, useRef, useState} from 'react'; import type {NativeEventSubscription} from 'react-native'; import {AppState, Linking, Platform} from 'react-native'; @@ -331,11 +330,6 @@ function Expensify() { // eslint-disable-next-line react-hooks/exhaustive-deps -- we don't want this effect to run again }, []); - // This is being done since we want to play sound even when iOS device is on silent mode, to align with other platforms. - useEffect(() => { - Audio.setAudioModeAsync({playsInSilentModeIOS: true}); - }, []); - useEffect(() => { if (isLoadingOnyxValue(sessionMetadata)) { return; diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 5789c2daecfb..9037780c92cb 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -650,6 +650,9 @@ const ONYXKEYS = { /** Keeps track of whether the "Confirm Navigate to Expensify Classic" modal is opened */ IS_OPEN_CONFIRM_NAVIGATE_EXPENSIFY_CLASSIC_MODAL_OPEN: 'IsOpenConfirmNavigateExpensifyClassicModalOpen', + /** The transaction IDs to be highlighted when opening the Expenses search route page */ + TRANSACTION_IDS_HIGHLIGHT_ON_SEARCH_ROUTE: 'transactionIdsHighlightOnSearchRoute', + /** Collection Keys */ COLLECTION: { DOMAIN: 'domain_', @@ -999,7 +1002,6 @@ const ONYXKEYS = { REPORT_ATTRIBUTES: 'reportAttributes', REPORT_TRANSACTIONS_AND_VIOLATIONS: 'reportTransactionsAndViolations', OUTSTANDING_REPORTS_BY_POLICY_ID: 'outstandingReportsByPolicyID', - VISIBLE_REPORT_ACTIONS: 'visibleReportActions', NON_PERSONAL_AND_WORKSPACE_CARD_LIST: 'nonPersonalAndWorkspaceCardList', CARD_FEED_ERRORS: 'cardFeedErrors', }, @@ -1411,13 +1413,13 @@ type OnyxValuesMapping = { [ONYXKEYS.HAS_DENIED_CONTACT_IMPORT_PROMPT]: boolean | undefined; [ONYXKEYS.IS_OPEN_CONFIRM_NAVIGATE_EXPENSIFY_CLASSIC_MODAL_OPEN]: boolean; [ONYXKEYS.PERSONAL_POLICY_ID]: string; + [ONYXKEYS.TRANSACTION_IDS_HIGHLIGHT_ON_SEARCH_ROUTE]: Record>; }; type OnyxDerivedValuesMapping = { [ONYXKEYS.DERIVED.REPORT_ATTRIBUTES]: OnyxTypes.ReportAttributesDerivedValue; [ONYXKEYS.DERIVED.REPORT_TRANSACTIONS_AND_VIOLATIONS]: OnyxTypes.ReportTransactionsAndViolationsDerivedValue; [ONYXKEYS.DERIVED.OUTSTANDING_REPORTS_BY_POLICY_ID]: OnyxTypes.OutstandingReportsByPolicyIDDerivedValue; - [ONYXKEYS.DERIVED.VISIBLE_REPORT_ACTIONS]: OnyxTypes.VisibleReportActionsDerivedValue; [ONYXKEYS.DERIVED.NON_PERSONAL_AND_WORKSPACE_CARD_LIST]: OnyxTypes.NonPersonalAndWorkspaceCardListDerivedValue; [ONYXKEYS.DERIVED.CARD_FEED_ERRORS]: OnyxTypes.CardFeedErrorsDerivedValue; }; diff --git a/src/components/Attachments/AttachmentCarousel/extractAttachments.ts b/src/components/Attachments/AttachmentCarousel/extractAttachments.ts index df0d08946c19..6dffd994d6a7 100644 --- a/src/components/Attachments/AttachmentCarousel/extractAttachments.ts +++ b/src/components/Attachments/AttachmentCarousel/extractAttachments.ts @@ -3,11 +3,11 @@ import type {OnyxEntry} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; import type {Attachment} from '@components/Attachments/types'; import {getFileName, splitExtensionFromFileName} from '@libs/fileDownload/FileUtils'; -import {getHtmlWithAttachmentID, getReportActionHtml, getReportActionMessage, getSortedReportActions, isMoneyRequestAction, isReportActionVisible} from '@libs/ReportActionsUtils'; +import {getHtmlWithAttachmentID, getReportActionHtml, getReportActionMessage, getSortedReportActions, isMoneyRequestAction, shouldReportActionBeVisible} from '@libs/ReportActionsUtils'; import {canUserPerformWriteAction} from '@libs/ReportUtils'; import tryResolveUrlFromApiRoot from '@libs/tryResolveUrlFromApiRoot'; import CONST from '@src/CONST'; -import type {Report, ReportAction, ReportActions, VisibleReportActionsDerivedValue} from '@src/types/onyx'; +import type {Report, ReportAction, ReportActions} from '@src/types/onyx'; import type {Note} from '@src/types/onyx/Report'; /** @@ -22,7 +22,6 @@ function extractAttachments( reportActions, report, isReportArchived, - visibleReportActionsData, }: { privateNotes?: Record; accountID?: number; @@ -30,7 +29,6 @@ function extractAttachments( reportActions?: OnyxEntry; report: OnyxEntry; isReportArchived: boolean | undefined; - visibleReportActionsData?: VisibleReportActionsDerivedValue; }, ) { const targetNote = privateNotes?.[Number(accountID)]?.note ?? ''; @@ -117,13 +115,9 @@ function extractAttachments( return attachments.reverse(); } - const reportID = report?.reportID; - if (!reportID) { - return attachments.reverse(); - } const actions = [...(parentReportAction ? [parentReportAction] : []), ...getSortedReportActions(Object.values(reportActions ?? {}))]; - for (const action of actions) { - if (!isReportActionVisible(action, reportID, canUserPerformAction, visibleReportActionsData) || isMoneyRequestAction(action)) { + for (const [key, action] of actions.entries()) { + if (!shouldReportActionBeVisible(action, key, canUserPerformAction) || isMoneyRequestAction(action)) { continue; } diff --git a/src/components/FloatingCameraButton/BaseFloatingCameraButton.tsx b/src/components/FloatingCameraButton/BaseFloatingCameraButton.tsx index 2e7039422d2b..b0f424e3303a 100644 --- a/src/components/FloatingCameraButton/BaseFloatingCameraButton.tsx +++ b/src/components/FloatingCameraButton/BaseFloatingCameraButton.tsx @@ -60,7 +60,7 @@ function BaseFloatingCameraButton({icon}: BaseFloatingCameraButtonProps) { const quickActionReportID = policyChatForActivePolicy?.reportID ?? reportID; Tab.setSelectedTab(CONST.TAB.IOU_REQUEST_TYPE, CONST.IOU.REQUEST_TYPE.SCAN); - startMoneyRequest(CONST.IOU.TYPE.CREATE, quickActionReportID, CONST.IOU.REQUEST_TYPE.SCAN, !!policyChatForActivePolicy?.reportID, undefined, allTransactionDrafts); + startMoneyRequest(CONST.IOU.TYPE.CREATE, quickActionReportID, CONST.IOU.REQUEST_TYPE.SCAN, !!policyChatForActivePolicy?.reportID, undefined, allTransactionDrafts, true); }); }; diff --git a/src/components/LHNOptionsList/LHNOptionsList.tsx b/src/components/LHNOptionsList/LHNOptionsList.tsx index 4572b1e35d79..41047755eb59 100644 --- a/src/components/LHNOptionsList/LHNOptionsList.tsx +++ b/src/components/LHNOptionsList/LHNOptionsList.tsx @@ -29,20 +29,20 @@ import Log from '@libs/Log'; import {getMovedReportID} from '@libs/ModifiedExpenseMessage'; import {getIOUReportIDOfLastAction, getLastMessageTextForReport} from '@libs/OptionsListUtils'; import { - getLastVisibleAction, getOneTransactionThreadReportID, getOriginalMessage, - getReportActionActorAccountID, + getSortedReportActions, + getSortedReportActionsForDisplay, isInviteOrRemovedAction, isMoneyRequestAction, - isReportPreviewAction, + shouldReportActionBeVisibleAsLastAction, } from '@libs/ReportActionsUtils'; import {canUserPerformWriteAction as canUserPerformWriteActionUtil} from '@libs/ReportUtils'; import variables from '@styles/variables'; import CONST from '@src/CONST'; import NAVIGATORS from '@src/NAVIGATORS'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {PersonalDetails, Report} from '@src/types/onyx'; +import type {PersonalDetails, Report, ReportAction} from '@src/types/onyx'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import OptionRowLHNData from './OptionRowLHNData'; import OptionRowRendererComponent from './OptionRowRendererComponent'; @@ -73,7 +73,6 @@ function LHNOptionsList({style, contentContainerStyles, data, onSelectRow, optio const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {canBeMissing: true}); const [onboarding] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {canBeMissing: true}); const [isFullscreenVisible] = useOnyx(ONYXKEYS.FULLSCREEN_VISIBILITY, {canBeMissing: true}); - const [visibleReportActionsData] = useOnyx(ONYXKEYS.DERIVED.VISIBLE_REPORT_ACTIONS, {canBeMissing: true}); const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); const {policyForMovingExpensesID} = usePolicyForMovingExpenses(); @@ -194,6 +193,9 @@ function LHNOptionsList({style, contentContainerStyles, data, onSelectRow, optio } const itemInvoiceReceiverPolicy = policy?.[`${ONYXKEYS.COLLECTION.POLICY}${invoiceReceiverPolicyID}`]; + const iouReportIDOfLastAction = getIOUReportIDOfLastAction(item); + const itemIouReportReportActions = iouReportIDOfLastAction ? reportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReportIDOfLastAction}`] : undefined; + const itemPolicy = policy?.[`${ONYXKEYS.COLLECTION.POLICY}${item?.policyID}`]; const transactionID = isMoneyRequestAction(itemParentReportAction) ? (getOriginalMessage(itemParentReportAction)?.IOUTransactionID ?? CONST.DEFAULT_NUMBER_ID) @@ -205,57 +207,56 @@ function LHNOptionsList({style, contentContainerStyles, data, onSelectRow, optio const isReportArchived = !!itemReportNameValuePairs?.private_isArchived; const canUserPerformWrite = canUserPerformWriteActionUtil(item, isReportArchived); - const lastAction = getLastVisibleAction( - reportID, - canUserPerformWrite, - {}, - itemReportActions ? {[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`]: itemReportActions} : undefined, - visibleReportActionsData, - ); + const sortedReportActions = getSortedReportActionsForDisplay(itemReportActions, canUserPerformWrite); + const lastReportAction = sortedReportActions.at(0); - const iouReportIDOfLastAction = getIOUReportIDOfLastAction(item, visibleReportActionsData, lastAction); - const itemIouReportReportActions = iouReportIDOfLastAction ? reportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReportIDOfLastAction}`] : undefined; - - const lastReportActionTransactionID = isMoneyRequestAction(lastAction) ? (getOriginalMessage(lastAction)?.IOUTransactionID ?? CONST.DEFAULT_NUMBER_ID) : CONST.DEFAULT_NUMBER_ID; + // Get the transaction for the last report action + const lastReportActionTransactionID = isMoneyRequestAction(lastReportAction) + ? (getOriginalMessage(lastReportAction)?.IOUTransactionID ?? CONST.DEFAULT_NUMBER_ID) + : CONST.DEFAULT_NUMBER_ID; const lastReportActionTransaction = transactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${lastReportActionTransactionID}`]; - const lastActorAccountID = getReportActionActorAccountID(lastAction, undefined, item) ?? item.lastActorAccountID; - let lastActorDetails: Partial | null = lastActorAccountID && personalDetails?.[lastActorAccountID] ? personalDetails[lastActorAccountID] : null; - - if (!lastActorDetails && lastAction) { - const lastActorDisplayName = lastAction?.person?.[0]?.text; + // SidebarUtils.getOptionData in OptionRowLHNData does not get re-evaluated when the linked task report changes, so we have the lastMessageTextFromReport evaluation logic here + let lastActorDetails: Partial | null = item?.lastActorAccountID && personalDetails?.[item.lastActorAccountID] ? personalDetails[item.lastActorAccountID] : null; + if (!lastActorDetails && lastReportAction) { + const lastActorDisplayName = lastReportAction?.person?.[0]?.text; lastActorDetails = lastActorDisplayName ? { displayName: lastActorDisplayName, - accountID: lastActorAccountID, + accountID: item?.lastActorAccountID, } : null; } - - const movedFromReport = reports?.[`${ONYXKEYS.COLLECTION.REPORT}${getMovedReportID(lastAction, CONST.REPORT.MOVE_TYPE.FROM)}`]; - const movedToReport = reports?.[`${ONYXKEYS.COLLECTION.REPORT}${getMovedReportID(lastAction, CONST.REPORT.MOVE_TYPE.TO)}`]; + const movedFromReport = reports?.[`${ONYXKEYS.COLLECTION.REPORT}${getMovedReportID(lastReportAction, CONST.REPORT.MOVE_TYPE.FROM)}`]; + const movedToReport = reports?.[`${ONYXKEYS.COLLECTION.REPORT}${getMovedReportID(lastReportAction, CONST.REPORT.MOVE_TYPE.TO)}`]; const itemReportMetadata = reportMetadataCollection?.[`${ONYXKEYS.COLLECTION.REPORT_METADATA}${reportID}`]; - - const shouldAlwaysRecalculateMessage = isReportArchived || isReportPreviewAction(lastAction); - const lastMessageTextFromReport = - (shouldAlwaysRecalculateMessage ? undefined : item.lastMessageText) ?? - getLastMessageTextForReport({ - translate, - report: item, - lastActorDetails, - movedFromReport, - movedToReport, - policy: itemPolicy, - isReportArchived, - policyForMovingExpensesID, - reportMetadata: itemReportMetadata, - visibleReportActionsDataParam: visibleReportActionsData, - lastAction, - currentUserAccountID, - }); + const lastMessageTextFromReport = getLastMessageTextForReport({ + translate, + report: item, + lastActorDetails, + movedFromReport, + movedToReport, + policy: itemPolicy, + isReportArchived: !!itemReportNameValuePairs?.private_isArchived, + policyForMovingExpensesID, + reportMetadata: itemReportMetadata, + currentUserAccountID, + }); const shouldShowRBRorGBRTooltip = firstReportIDWithGBRorRBR === reportID; + let lastAction: ReportAction | undefined; + if (!itemReportActions || !item) { + lastAction = undefined; + } else { + const canUserPerformWriteAction = canUserPerformWriteActionUtil(item, isReportArchived); + const actionsArray = getSortedReportActions(Object.values(itemReportActions)); + const reportActionsForDisplay = actionsArray.filter( + (reportAction) => shouldReportActionBeVisibleAsLastAction(reportAction, canUserPerformWriteAction) && reportAction.actionName !== CONST.REPORT.ACTIONS.TYPE.CREATED, + ); + lastAction = reportActionsForDisplay.at(-1); + } + let lastActionReport: OnyxEntry | undefined; if (isInviteOrRemovedAction(lastAction)) { const lastActionOriginalMessage = lastAction?.actionName ? getOriginalMessage(lastAction) : null; @@ -330,7 +331,6 @@ function LHNOptionsList({style, contentContainerStyles, data, onSelectRow, optio isScreenFocused, localeCompare, translate, - visibleReportActionsData, currentUserAccountID, ], ); diff --git a/src/components/LHNOptionsList/OptionRowLHNData.tsx b/src/components/LHNOptionsList/OptionRowLHNData.tsx index da78a912afe6..b14bf5302973 100644 --- a/src/components/LHNOptionsList/OptionRowLHNData.tsx +++ b/src/components/LHNOptionsList/OptionRowLHNData.tsx @@ -51,7 +51,6 @@ function OptionRowLHNData({ const [movedFromReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getMovedReportID(lastAction, CONST.REPORT.MOVE_TYPE.FROM)}`, {canBeMissing: true}); const [movedToReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getMovedReportID(lastAction, CONST.REPORT.MOVE_TYPE.TO)}`, {canBeMissing: true}); - const [visibleReportActionsData] = useOnyx(ONYXKEYS.DERIVED.VISIBLE_REPORT_ACTIONS, {canBeMissing: true}); // Check the report errors equality to avoid re-rendering when there are no changes const prevReportErrors = usePrevious(reportAttributes?.reportErrors); const areReportErrorsEqual = useMemo(() => deepEqual(prevReportErrors, reportAttributes?.reportErrors), [prevReportErrors, reportAttributes?.reportErrors]); @@ -79,7 +78,6 @@ function OptionRowLHNData({ movedFromReport, movedToReport, currentUserAccountID, - visibleReportActionsData, }); if (deepEqual(item, optionItemRef.current)) { return optionItemRef.current; @@ -116,7 +114,6 @@ function OptionRowLHNData({ movedFromReport, movedToReport, currentUserAccountID, - visibleReportActionsData, ]); return ( diff --git a/src/components/MoneyRequestAmountInput.tsx b/src/components/MoneyRequestAmountInput.tsx index 0c30556f7512..989c6e12d43e 100644 --- a/src/components/MoneyRequestAmountInput.tsx +++ b/src/components/MoneyRequestAmountInput.tsx @@ -95,6 +95,9 @@ type MoneyRequestAmountInputProps = { /** Whether to allow flipping amount */ allowFlippingAmount?: boolean; + /** Whether to allow direct negative input (for split amounts where value is already negative) */ + allowNegativeInput?: boolean; + /** The testID of the input. Used to locate this view in end-to-end tests. */ testID?: string; @@ -161,6 +164,7 @@ function MoneyRequestAmountInput({ shouldWrapInputInContainer = true, isNegative = false, allowFlippingAmount = false, + allowNegativeInput = false, toggleNegative, clearNegative, ref, @@ -255,6 +259,7 @@ function MoneyRequestAmountInput({ autoGrowExtraSpace={autoGrowExtraSpace} submitBehavior={submitBehavior} allowFlippingAmount={allowFlippingAmount} + allowNegativeInput={allowNegativeInput} toggleNegative={toggleNegative} clearNegative={clearNegative} onFocus={props.onFocus} diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx index 3b5753f1c904..c21deb495d39 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx @@ -20,6 +20,7 @@ import ScrollView from '@components/ScrollView'; import {useSearchContext} from '@components/Search/SearchContext'; import Text from '@components/Text'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useFilterSelectedTransactions from '@hooks/useFilterSelectedTransactions'; import {AUTOSCROLL_TO_TOP_THRESHOLD} from '@hooks/useFlatListScrollKey'; import useLoadReportActions from '@hooks/useLoadReportActions'; import useLocalize from '@hooks/useLocalize'; @@ -50,7 +51,7 @@ import { isCurrentActionUnread, isDeletedParentAction, isIOUActionMatchingTransactionList, - isReportActionVisible, + shouldReportActionBeVisible, wasMessageReceivedWhileOffline, } from '@libs/ReportActionsUtils'; import {canUserPerformWriteAction, chatIncludesChronosWithID, getOriginalReportID, getReportLastVisibleActionCreated, isHarvestCreatedExpenseReport, isUnread} from '@libs/ReportUtils'; @@ -171,7 +172,6 @@ function MoneyRequestReportActionsList({ const isReportArchived = useReportIsArchived(reportID); const canPerformWriteAction = canUserPerformWriteAction(report, isReportArchived); - const [visibleReportActionsData] = useOnyx(ONYXKEYS.DERIVED.VISIBLE_REPORT_ACTIONS, {canBeMissing: true}); const {shouldUseNarrowLayout} = useResponsiveLayoutOnWideRHP(); @@ -185,6 +185,8 @@ function MoneyRequestReportActionsList({ const {selectedTransactionIDs, setSelectedTransactions, clearSelectedTransactions} = useSearchContext(); + useFilterSelectedTransactions(transactions); + const isMobileSelectionModeEnabled = useMobileSelectionMode(); const [isExportWithTemplateModalVisible, setIsExportWithTemplateModalVisible] = useState(false); const beginExportWithTemplate = useCallback( @@ -232,29 +234,17 @@ function MoneyRequestReportActionsList({ const visibleReportActions = useMemo(() => { const filteredActions = reportActions.filter((reportAction) => { const isActionVisibleOnMoneyReport = isActionVisibleOnMoneyRequestReport(reportAction, shouldShowHarvestCreatedAction); - if (!isActionVisibleOnMoneyReport) { - return false; - } - - const passesOfflineCheck = isOffline || isDeletedParentAction(reportAction) || reportAction.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || reportAction.errors; - if (!passesOfflineCheck) { - return false; - } - - const actionReportID = reportAction.reportID ?? reportID; - if (!isReportActionVisible(reportAction, actionReportID, canPerformWriteAction, visibleReportActionsData)) { - return false; - } - - if (!isIOUActionMatchingTransactionList(reportAction, reportTransactionIDs)) { - return false; - } - return true; + return ( + isActionVisibleOnMoneyReport && + (isOffline || isDeletedParentAction(reportAction) || reportAction.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || reportAction.errors) && + shouldReportActionBeVisible(reportAction, reportAction.reportActionID, canPerformWriteAction) && + isIOUActionMatchingTransactionList(reportAction, reportTransactionIDs) + ); }); return filteredActions.toReversed(); - }, [reportActions, isOffline, canPerformWriteAction, reportTransactionIDs, shouldShowHarvestCreatedAction, visibleReportActionsData, reportID]); + }, [reportActions, isOffline, canPerformWriteAction, reportTransactionIDs, shouldShowHarvestCreatedAction]); const reportActionSize = useRef(visibleReportActions.length); const lastAction = visibleReportActions.at(-1); @@ -729,6 +719,7 @@ function MoneyRequestReportActionsList({ role="button" accessibilityState={{checked: isSelectAllChecked}} dataSet={{[CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT]: true}} + sentryLabel={CONST.SENTRY_LABEL.REPORT.MONEY_REQUEST_REPORT_ACTIONS_LIST_SELECT_ALL} > {translate('workspace.people.selectAll')} diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx index 7f6c2bd21a48..baa9715be4dd 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx @@ -1,4 +1,4 @@ -import {useFocusEffect} from '@react-navigation/native'; +import {findFocusedRoute, useFocusEffect} from '@react-navigation/native'; import isEmpty from 'lodash/isEmpty'; import React, {memo, useCallback, useEffect, useMemo, useState} from 'react'; import {View} from 'react-native'; @@ -14,6 +14,7 @@ import Text from '@components/Text'; import {useWideRHPActions} from '@components/WideRHPContextProvider'; import useCopySelectionHelper from '@hooks/useCopySelectionHelper'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useDeepCompareRef from '@hooks/useDeepCompareRef'; import useHandleSelectionMode from '@hooks/useHandleSelectionMode'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; @@ -27,7 +28,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {turnOnMobileSelectionMode} from '@libs/actions/MobileSelectionMode'; import {setOptimisticTransactionThread} from '@libs/actions/Report'; import {getReportLayoutGroupBy} from '@libs/actions/ReportLayout'; -import {setActiveTransactionIDs} from '@libs/actions/TransactionThreadNavigation'; +import {clearActiveTransactionIDs, setActiveTransactionIDs} from '@libs/actions/TransactionThreadNavigation'; import {convertToDisplayString} from '@libs/CurrencyUtils'; import {hasNonReimbursableTransactions, isBillableEnabledOnPolicy} from '@libs/MoneyRequestReportUtils'; import {navigationRef} from '@libs/Navigation/Navigation'; @@ -66,7 +67,7 @@ import type {TranslationPaths} from '@src/languages/types'; import NAVIGATORS from '@src/NAVIGATORS'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import type SCREENS from '@src/SCREENS'; +import SCREENS from '@src/SCREENS'; import type * as OnyxTypes from '@src/types/onyx'; import type {PendingAction} from '@src/types/onyx/OnyxCommon'; import MoneyRequestReportGroupHeader from './MoneyRequestReportGroupHeader'; @@ -311,6 +312,18 @@ function MoneyRequestReportTransactionList({ return groupedTransactions.flatMap((group) => group.transactions.filter((transaction) => !isTransactionPendingDelete(transaction)).map((transaction) => transaction.transactionID)); }, [groupedTransactions, sortedTransactions, shouldShowGroupedTransactions]); + const visualOrderTransactionIDsDeepCompare = useDeepCompareRef(visualOrderTransactionIDs); + useEffect(() => { + const focusedRoute = findFocusedRoute(navigationRef.getRootState()); + if (focusedRoute?.name !== SCREENS.RIGHT_MODAL.SEARCH_REPORT) { + return; + } + setActiveTransactionIDs(visualOrderTransactionIDsDeepCompare ?? []); + return () => { + clearActiveTransactionIDs(); + }; + }, [visualOrderTransactionIDsDeepCompare]); + const sortedTransactionsMap = useMemo(() => { const map = new Map(); for (const transaction of sortedTransactions) { diff --git a/src/components/NumberWithSymbolForm.tsx b/src/components/NumberWithSymbolForm.tsx index 10b373a81660..a81f4746fee8 100644 --- a/src/components/NumberWithSymbolForm.tsx +++ b/src/components/NumberWithSymbolForm.tsx @@ -79,6 +79,9 @@ type NumberWithSymbolFormProps = { /** Whether to allow flipping amount */ allowFlippingAmount?: boolean; + /** Whether to allow direct negative input (for split amounts where value is already negative) */ + allowNegativeInput?: boolean; + /** Whether the input is disabled or not */ disabled?: boolean; @@ -144,6 +147,7 @@ function NumberWithSymbolForm({ shouldWrapInputInContainer = true, isNegative = false, allowFlippingAmount = false, + allowNegativeInput = false, toggleNegative, clearNegative, ref, @@ -218,11 +222,13 @@ function NumberWithSymbolForm({ const newNumberWithoutSpaces = stripSpacesFromAmount(newNumber); const rawFinalNumber = newNumberWithoutSpaces.includes('.') ? stripCommaFromAmount(newNumberWithoutSpaces) : replaceCommasWithPeriod(newNumberWithoutSpaces); - const finalNumber = handleNegativeAmountFlipping(rawFinalNumber, allowFlippingAmount, toggleNegative); + // When allowNegativeInput is true, keep negative sign as-is (for split amounts) + // When allowFlippingAmount is true, strip the negative sign and call toggleNegative + const finalNumber = allowNegativeInput ? rawFinalNumber : handleNegativeAmountFlipping(rawFinalNumber, allowFlippingAmount, toggleNegative); // Use a shallow copy of selection to trigger setSelection // More info: https://github.com/Expensify/App/issues/16385 - if (!validateAmount(finalNumber, decimals, maxLength)) { + if (!validateAmount(finalNumber, decimals, maxLength, allowNegativeInput)) { setSelection((prevSelection) => ({...prevSelection})); return; } @@ -242,7 +248,7 @@ function NumberWithSymbolForm({ }); onInputChange?.(strippedNumber); }, - [decimals, maxLength, onInputChange, allowFlippingAmount, toggleNegative], + [decimals, maxLength, onInputChange, allowFlippingAmount, toggleNegative, allowNegativeInput], ); /** @@ -253,11 +259,14 @@ function NumberWithSymbolForm({ // Remove spaces from the new number because Safari on iOS adds spaces when pasting a copied number // More info: https://github.com/Expensify/App/issues/16974 const newNumberWithoutSpaces = stripSpacesFromAmount(text); - const replacedCommasNumber = handleNegativeAmountFlipping(replaceCommasWithPeriod(newNumberWithoutSpaces), allowFlippingAmount, toggleNegative); + // When allowNegativeInput is true, keep negative sign as-is + const replacedCommasNumber = allowNegativeInput + ? replaceCommasWithPeriod(newNumberWithoutSpaces) + : handleNegativeAmountFlipping(replaceCommasWithPeriod(newNumberWithoutSpaces), allowFlippingAmount, toggleNegative); - const withLeadingZero = addLeadingZero(replacedCommasNumber); + const withLeadingZero = addLeadingZero(replacedCommasNumber, allowNegativeInput); - if (!validateAmount(withLeadingZero, decimals, maxLength)) { + if (!validateAmount(withLeadingZero, decimals, maxLength, allowNegativeInput)) { setSelection((prevSelection) => ({...prevSelection})); return; } @@ -280,7 +289,7 @@ function NumberWithSymbolForm({ // Modifies the number to match changed decimals. useEffect(() => { // If the number supports decimals, we can return - if (validateAmount(currentNumber, decimals, maxLength, allowFlippingAmount)) { + if (validateAmount(currentNumber, decimals, maxLength, allowNegativeInput || allowFlippingAmount)) { return; } @@ -305,14 +314,14 @@ function NumberWithSymbolForm({ if (currentNumber.length > 0) { const selectionStart = selection.start === selection.end ? selection.start - 1 : selection.start; const newNumber = `${currentNumber.substring(0, selectionStart)}${currentNumber.substring(selection.end)}`; - setNewNumber(addLeadingZero(newNumber)); + setNewNumber(addLeadingZero(newNumber, allowNegativeInput)); } return; } - const newNumber = addLeadingZero(`${currentNumber.substring(0, selection.start)}${key}${currentNumber.substring(selection.end)}`); + const newNumber = addLeadingZero(`${currentNumber.substring(0, selection.start)}${key}${currentNumber.substring(selection.end)}`, allowNegativeInput); setNewNumber(newNumber); }, - [currentNumber, selection.start, selection.end, shouldUpdateSelection, setNewNumber], + [currentNumber, selection.start, selection.end, shouldUpdateSelection, setNewNumber, allowNegativeInput], ); /** diff --git a/src/components/Onfido/index.native.tsx b/src/components/Onfido/index.native.tsx index 917a5186317b..d29285dc8bc1 100644 --- a/src/components/Onfido/index.native.tsx +++ b/src/components/Onfido/index.native.tsx @@ -1,10 +1,11 @@ import {OnfidoCaptureType, OnfidoCountryCode, OnfidoDocumentType, OnfidoNFCOptions, Onfido as OnfidoSDK, OnfidoTheme} from '@onfido/react-native-sdk'; import React, {useEffect} from 'react'; -import {Alert, Linking, NativeModules} from 'react-native'; +import {Alert, NativeModules} from 'react-native'; import {checkMultiple, PERMISSIONS, RESULTS} from 'react-native-permissions'; import FullscreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; import useLocalize from '@hooks/useLocalize'; import getPlatform from '@libs/getPlatform'; +import goToSettings from '@libs/goToSettings'; import Log from '@libs/Log'; import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; @@ -81,7 +82,7 @@ function Onfido({sdkToken, onUserExit, onSuccess, onError}: OnfidoProps) { text: translate('common.settings'), onPress: () => { onUserExit(); - Linking.openSettings(); + goToSettings(); }, }, ], diff --git a/src/components/ParentNavigationSubtitle.tsx b/src/components/ParentNavigationSubtitle.tsx index e04d280a1628..cb1d3ea4939c 100644 --- a/src/components/ParentNavigationSubtitle.tsx +++ b/src/components/ParentNavigationSubtitle.tsx @@ -13,7 +13,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {isFullScreenName} from '@libs/Navigation/helpers/isNavigatorName'; import Navigation from '@libs/Navigation/Navigation'; import type {RightModalNavigatorParamList} from '@libs/Navigation/types'; -import {getReportAction, isReportActionVisible} from '@libs/ReportActionsUtils'; +import {getReportAction, shouldReportActionBeVisible} from '@libs/ReportActionsUtils'; import {canUserPerformWriteAction as canUserPerformWriteActionReportUtils, isMoneyRequestReport} from '@libs/ReportUtils'; import CONST from '@src/CONST'; import type {ParentNavigationSummaryParams} from '@src/languages/params'; @@ -88,7 +88,6 @@ function ParentNavigationSubtitle({ const {translate} = useLocalize(); const [currentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, {canBeMissing: false}); const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${parentReportID}`, {canBeMissing: false}); - const [visibleReportActionsData] = useOnyx(ONYXKEYS.DERIVED.VISIBLE_REPORT_ACTIONS, {canBeMissing: true}); const isReportArchived = useReportIsArchived(report?.reportID); const canUserPerformWriteAction = canUserPerformWriteActionReportUtils(report, isReportArchived); const isReportInRHP = currentRoute.name === SCREENS.RIGHT_MODAL.SEARCH_REPORT; @@ -117,7 +116,7 @@ function ParentNavigationSubtitle({ const onPress = () => { const parentAction = getReportAction(parentReportID, parentReportActionID); - const isVisibleAction = isReportActionVisible(parentAction, parentReportID, canUserPerformWriteAction, visibleReportActionsData); + const isVisibleAction = shouldReportActionBeVisible(parentAction, parentAction?.reportActionID ?? CONST.DEFAULT_NUMBER_ID, canUserPerformWriteAction); const focusedNavigatorState = currentFocusedNavigator?.state; const currentReportIndex = focusedNavigatorState?.index; diff --git a/src/components/PopoverMenu.tsx b/src/components/PopoverMenu.tsx index 701e604c9dfd..58b2844ba2d2 100644 --- a/src/components/PopoverMenu.tsx +++ b/src/components/PopoverMenu.tsx @@ -175,6 +175,8 @@ type PopoverMenuProps = Partial & { /** Used to locate the component in the tests */ testID?: string; + /** Whether to handle navigation back */ + shouldHandleNavigationBack?: boolean; /** Badge style to be shown near the right end. */ badgeStyle?: StyleProp; }; @@ -295,6 +297,7 @@ function BasePopoverMenu({ shouldAvoidSafariException = false, shouldMaintainFocusAfterSubItemSelect: shouldPreserveFocusOnSubItems = true, testID, + shouldHandleNavigationBack = false, }: PopoverMenuProps) { const styles = useThemeStyles(); const theme = useTheme(); @@ -596,6 +599,7 @@ function BasePopoverMenu({ innerContainerStyle={{...styles.pv0, ...innerContainerStyle}} shouldUseModalPaddingStyle={shouldUseModalPaddingStyle} testID={testID} + shouldHandleNavigationBack={shouldHandleNavigationBack} > ; -} & Pick; +} & Pick; function SearchAutocompleteInput({ value, @@ -94,6 +94,7 @@ function SearchAutocompleteInput({ isSearchingForReports, selection, substitutionMap, + onKeyPress, ref, }: SearchAutocompleteInputProps) { const styles = useThemeStyles(); @@ -223,6 +224,7 @@ function SearchAutocompleteInput({ onBlur?.(); }} + onKeyPress={onKeyPress} isLoading={isSearchingForReports} ref={(element) => { if (!ref) { diff --git a/src/components/Search/SearchAutocompleteList.tsx b/src/components/Search/SearchAutocompleteList.tsx index e839504b4989..42a7aee121ce 100644 --- a/src/components/Search/SearchAutocompleteList.tsx +++ b/src/components/Search/SearchAutocompleteList.tsx @@ -196,7 +196,6 @@ function SearchAutocompleteList({ const [recentSearches] = useOnyx(ONYXKEYS.RECENT_SEARCHES, {canBeMissing: true}); const [countryCode] = useOnyx(ONYXKEYS.COUNTRY_CODE, {canBeMissing: false}); const [loginList] = useOnyx(ONYXKEYS.LOGIN_LIST, {canBeMissing: true}); - const [visibleReportActionsData] = useOnyx(ONYXKEYS.DERIVED.VISIBLE_REPORT_ACTIONS, {canBeMissing: true}); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const currentUserEmail = currentUserPersonalDetails.email ?? ''; const currentUserAccountID = currentUserPersonalDetails.accountID; @@ -223,23 +222,10 @@ function SearchAutocompleteList({ shouldShowGBR: false, shouldUnreadBeBold: true, loginList, - visibleReportActionsData, currentUserAccountID, currentUserEmail, }); - }, [ - areOptionsInitialized, - options, - draftComments, - nvpDismissedProductTraining, - betas, - autocompleteQueryValue, - countryCode, - loginList, - visibleReportActionsData, - currentUserAccountID, - currentUserEmail, - ]); + }, [areOptionsInitialized, options, draftComments, nvpDismissedProductTraining, betas, autocompleteQueryValue, countryCode, loginList, currentUserAccountID, currentUserEmail]); const [isInitialRender, setIsInitialRender] = useState(true); const parsedQuery = useMemo(() => parseForAutocomplete(autocompleteQueryValue), [autocompleteQueryValue]); @@ -450,7 +436,6 @@ function SearchAutocompleteList({ countryCode, loginList, shouldShowGBR: true, - visibleReportActionsData, currentUserAccountID, currentUserEmail, }).personalDetails.filter((participant) => participant.text && !alreadyAutocompletedKeys.has(participant.text.toLowerCase())); @@ -484,7 +469,6 @@ function SearchAutocompleteList({ countryCode, loginList, shouldShowGBR: true, - visibleReportActionsData, currentUserAccountID, currentUserEmail, }).recentReports.filter((chat) => { @@ -673,7 +657,6 @@ function SearchAutocompleteList({ workspaceList, hasAutocompleteList, isAutocompleteList, - visibleReportActionsData, ]); const sortedRecentSearches = useMemo(() => { diff --git a/src/components/Search/SearchFiltersChatsSelector.tsx b/src/components/Search/SearchFiltersChatsSelector.tsx index c0909488fc95..6c221eecde93 100644 --- a/src/components/Search/SearchFiltersChatsSelector.tsx +++ b/src/components/Search/SearchFiltersChatsSelector.tsx @@ -64,25 +64,17 @@ function SearchFiltersChatsSelector({initialReportIDs, onFiltersUpdate, isScreen const [draftComments] = useOnyx(ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT, {canBeMissing: true}); const archivedReportsIdSet = useArchivedReportsIdSet(); const [nvpDismissedProductTraining] = useOnyx(ONYXKEYS.NVP_DISMISSED_PRODUCT_TRAINING, {canBeMissing: true}); - const [visibleReportActionsData] = useOnyx(ONYXKEYS.DERIVED.VISIBLE_REPORT_ACTIONS, {canBeMissing: true}); const selectedOptions = useMemo(() => { return selectedReportIDs.map((id) => { const report = getSelectedOptionData( - createOptionFromReport( - {...reports?.[`${ONYXKEYS.COLLECTION.REPORT}${id}`], reportID: id}, - personalDetails, - currentUserAccountID, - reportAttributesDerived, - undefined, - visibleReportActionsData, - ), + createOptionFromReport({...reports?.[`${ONYXKEYS.COLLECTION.REPORT}${id}`], reportID: id}, personalDetails, currentUserAccountID, reportAttributesDerived), ); const isReportArchived = archivedReportsIdSet.has(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report.reportID}`); - const alternateText = getAlternateText(report, {}, isReportArchived, currentUserAccountID, {}, visibleReportActionsData); + const alternateText = getAlternateText(report, {}, isReportArchived, currentUserAccountID, {}); return {...report, alternateText}; }); - }, [archivedReportsIdSet, personalDetails, reportAttributesDerived, reports, selectedReportIDs, currentUserAccountID, visibleReportActionsData]); + }, [archivedReportsIdSet, personalDetails, reportAttributesDerived, reports, selectedReportIDs, currentUserAccountID]); const defaultOptions = useMemo(() => { if (!areOptionsInitialized || !isScreenTransitionEnd) { @@ -96,22 +88,10 @@ function SearchFiltersChatsSelector({initialReportIDs, onFiltersUpdate, isScreen isUsedInChatFinder: false, countryCode, loginList, - visibleReportActionsData, currentUserAccountID, currentUserEmail, }); - }, [ - areOptionsInitialized, - isScreenTransitionEnd, - options, - draftComments, - nvpDismissedProductTraining, - countryCode, - loginList, - visibleReportActionsData, - currentUserAccountID, - currentUserEmail, - ]); + }, [areOptionsInitialized, isScreenTransitionEnd, options, draftComments, nvpDismissedProductTraining, countryCode, loginList, currentUserAccountID, currentUserEmail]); const chatOptions = useMemo(() => { return filterAndOrderOptions(defaultOptions, cleanSearchTerm, countryCode, loginList, currentUserEmail, currentUserAccountID, { diff --git a/src/components/Search/SearchFiltersParticipantsSelector.tsx b/src/components/Search/SearchFiltersParticipantsSelector.tsx index b6c399deb539..b4496e6b32bf 100644 --- a/src/components/Search/SearchFiltersParticipantsSelector.tsx +++ b/src/components/Search/SearchFiltersParticipantsSelector.tsx @@ -11,7 +11,7 @@ import useOnyx from '@hooks/useOnyx'; import useScreenWrapperTransitionStatus from '@hooks/useScreenWrapperTransitionStatus'; import {canUseTouchScreen} from '@libs/DeviceCapabilities'; import memoize from '@libs/memoize'; -import {filterAndOrderOptions, filterSelectedOptions, formatSectionsFromSearchTerm, getValidOptions} from '@libs/OptionsListUtils'; +import {filterAndOrderOptions, filterSelectedOptions, formatSectionsFromSearchTerm, getFilteredRecentAttendees, getValidOptions} from '@libs/OptionsListUtils'; import type {Option, Section} from '@libs/OptionsListUtils'; import type {OptionData} from '@libs/ReportUtils'; import {getDisplayNameForParticipant} from '@libs/ReportUtils'; @@ -19,6 +19,7 @@ import Navigation from '@navigation/Navigation'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; +import type {Attendee} from '@src/types/onyx/IOU'; import SearchFilterPageFooterButtons from './SearchFilterPageFooterButtons'; const defaultListOptions = { @@ -36,12 +37,43 @@ function getSelectedOptionData(option: Option): OptionData { return {...option, selected: true, reportID: option.reportID ?? '-1'}; } +/** + * Creates an OptionData object from a name-only attendee (attendee without a real accountID in personalDetails) + */ +function getOptionDataFromAttendee(attendee: Attendee): OptionData { + return { + text: attendee.displayName, + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- need || to handle empty string email + alternateText: attendee.email || attendee.displayName, + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- need || to handle empty string email + login: attendee.email || attendee.displayName, + displayName: attendee.displayName, + accountID: attendee.accountID ?? CONST.DEFAULT_NUMBER_ID, + // eslint-disable-next-line rulesdir/no-default-id-values + reportID: '-1', + selected: true, + icons: attendee.avatarUrl + ? [ + { + source: attendee.avatarUrl, + type: CONST.ICON_TYPE_AVATAR, + name: attendee.displayName, + }, + ] + : [], + searchText: attendee.searchText ?? attendee.displayName, + }; +} + type SearchFiltersParticipantsSelectorProps = { initialAccountIDs: string[]; onFiltersUpdate: (accountIDs: string[]) => void; + + /** Whether to allow name-only options (for attendee filter only) */ + shouldAllowNameOnlyOptions?: boolean; }; -function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate}: SearchFiltersParticipantsSelectorProps) { +function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate, shouldAllowNameOnlyOptions = false}: SearchFiltersParticipantsSelectorProps) { const {translate, formatPhoneNumber} = useLocalize(); const personalDetails = usePersonalDetails(); const {didScreenTransitionEnd} = useScreenWrapperTransitionStatus(); @@ -61,6 +93,14 @@ function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate}: const [draftComments] = useOnyx(ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT, {canBeMissing: true}); const [nvpDismissedProductTraining] = useOnyx(ONYXKEYS.NVP_DISMISSED_PRODUCT_TRAINING, {canBeMissing: true}); const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {canBeMissing: true}); + const [recentAttendees] = useOnyx(ONYXKEYS.NVP_RECENT_ATTENDEES, {canBeMissing: true}); + + // Transform raw recentAttendees into Option[] format for use with getValidOptions (only for attendee filter) + const recentAttendeeLists = useMemo( + () => (shouldAllowNameOnlyOptions ? getFilteredRecentAttendees(personalDetails, [], recentAttendees ?? [], currentUserEmail, currentUserAccountID) : []), + [personalDetails, recentAttendees, currentUserEmail, currentUserAccountID, shouldAllowNameOnlyOptions], + ); + const defaultOptions = useMemo(() => { if (!areOptionsInitialized) { return defaultListOptions; @@ -80,6 +120,10 @@ function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate}: { excludeLogins: CONST.EXPENSIFY_EMAILS_OBJECT, includeCurrentUser: true, + shouldAcceptName: shouldAllowNameOnlyOptions, + includeUserToInvite: shouldAllowNameOnlyOptions, + recentAttendees: recentAttendeeLists, + includeRecentReports: !shouldAllowNameOnlyOptions, personalDetails, }, countryCode, @@ -93,21 +137,47 @@ function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate}: nvpDismissedProductTraining, loginList, countryCode, + recentAttendeeLists, + shouldAllowNameOnlyOptions, personalDetails, currentUserAccountID, currentUserEmail, ]); const unselectedOptions = useMemo(() => { - return filterSelectedOptions(defaultOptions, new Set(selectedOptions.map((option) => option.accountID))); - }, [defaultOptions, selectedOptions]); + if (!shouldAllowNameOnlyOptions) { + return filterSelectedOptions(defaultOptions, new Set(selectedOptions.map((option) => option.accountID))); + } + + // For name-only options, filter by both accountID (for regular users) AND login (for name-only attendees) + const selectedAccountIDs = new Set(selectedOptions.map((option) => option.accountID).filter((id): id is number => !!id && id !== CONST.DEFAULT_NUMBER_ID)); + const selectedLogins = new Set(selectedOptions.map((option) => option.login).filter((login): login is string => !!login)); + + const isSelected = (option: {accountID?: number; login?: string}) => { + if (option.accountID && option.accountID !== CONST.DEFAULT_NUMBER_ID && selectedAccountIDs.has(option.accountID)) { + return true; + } + if (option.login && selectedLogins.has(option.login)) { + return true; + } + return false; + }; + + return { + ...defaultOptions, + personalDetails: defaultOptions.personalDetails.filter((option) => !isSelected(option)), + recentReports: defaultOptions.recentReports.filter((option) => !isSelected(option)), + }; + }, [defaultOptions, selectedOptions, shouldAllowNameOnlyOptions]); const chatOptions = useMemo(() => { const filteredOptions = filterAndOrderOptions(unselectedOptions, cleanSearchTerm, countryCode, loginList, currentUserEmail, currentUserAccountID, { selectedOptions, excludeLogins: CONST.EXPENSIFY_EMAILS_OBJECT, maxRecentReportsToShow: CONST.IOU.MAX_RECENT_REPORTS_TO_SHOW, - canInviteUser: false, + canInviteUser: shouldAllowNameOnlyOptions, + shouldAcceptName: shouldAllowNameOnlyOptions, + searchInputValue: searchTerm, }); const {currentUserOption} = unselectedOptions; @@ -118,7 +188,7 @@ function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate}: } return filteredOptions; - }, [unselectedOptions, cleanSearchTerm, countryCode, loginList, selectedOptions, currentUserAccountID, currentUserEmail]); + }, [unselectedOptions, cleanSearchTerm, countryCode, loginList, selectedOptions, shouldAllowNameOnlyOptions, searchTerm, currentUserEmail, currentUserAccountID]); const {sections, headerMessage} = useMemo(() => { const newSections: Section[] = []; @@ -165,10 +235,17 @@ function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate}: newSections.push(formattedResults.section); + // Filter current user from recentReports to avoid duplicate with currentUserOption section + // Only filter if both the report and currentUserOption have valid accountIDs to avoid + // accidentally filtering out name-only attendees (which have accountID: undefined) + const filteredRecentReports = chatOptions.recentReports.filter( + (report) => !report.accountID || !chatOptions.currentUserOption?.accountID || report.accountID !== chatOptions.currentUserOption.accountID, + ); + newSections.push({ title: '', - data: chatOptions.recentReports, - shouldShow: chatOptions.recentReports.length > 0, + data: filteredRecentReports, + shouldShow: filteredRecentReports.length > 0, }); newSections.push({ @@ -191,38 +268,108 @@ function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate}: }, []); const applyChanges = useCallback(() => { - const selectedAccountIDs = selectedOptions.map((option) => (option.accountID ? option.accountID.toString() : undefined)).filter(Boolean) as string[]; - onFiltersUpdate(selectedAccountIDs); + let selectedIdentifiers: string[]; + + if (shouldAllowNameOnlyOptions) { + selectedIdentifiers = selectedOptions + .map((option) => { + // For real users (with valid accountID in personalDetails), use accountID + if (option.accountID && option.accountID !== CONST.DEFAULT_NUMBER_ID && personalDetails?.[option.accountID]) { + return option.accountID.toString(); + } + + // For name-only attendees, use displayName or login as identifier + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- need || to handle empty string + return option.displayName || option.login; + }) + .filter(Boolean) as string[]; + } else { + selectedIdentifiers = selectedOptions.map((option) => (option.accountID ? option.accountID.toString() : undefined)).filter(Boolean) as string[]; + } + onFiltersUpdate(selectedIdentifiers); Navigation.goBack(ROUTES.SEARCH_ADVANCED_FILTERS.getRoute()); - }, [onFiltersUpdate, selectedOptions]); + }, [onFiltersUpdate, selectedOptions, personalDetails, shouldAllowNameOnlyOptions]); - // This effect handles setting initial selectedOptions based on accountIDs saved in onyx form + // This effect handles setting initial selectedOptions based on accountIDs (or displayNames for attendee filter) useEffect(() => { if (!initialAccountIDs || initialAccountIDs.length === 0 || !personalDetails) { return; } - const preSelectedOptions = initialAccountIDs - .map((accountID) => { - const participant = personalDetails[accountID]; - if (!participant) { - return; - } - - return getSelectedOptionData(participant); - }) - .filter((option): option is NonNullable => { - return !!option; - }); + let preSelectedOptions: OptionData[]; + + if (shouldAllowNameOnlyOptions) { + preSelectedOptions = initialAccountIDs + .map((identifier) => { + // First, try to look up as accountID in personalDetails + const participant = personalDetails[identifier]; + if (participant) { + return getSelectedOptionData(participant); + } + + // If not found in personalDetails, this might be a name-only attendee + // Search in recentAttendees by displayName or email + const attendee = recentAttendees?.find((recentAttendee) => recentAttendee.displayName === identifier || recentAttendee.email === identifier); + if (attendee) { + return getOptionDataFromAttendee(attendee); + } + + // Fallback: construct a minimal option from the identifier string to preserve + // name-only filters across sessions (e.g., after cache clear or on another device) + return { + text: identifier, + alternateText: identifier, + login: identifier, + displayName: identifier, + accountID: CONST.DEFAULT_NUMBER_ID, + // eslint-disable-next-line rulesdir/no-default-id-values + reportID: '-1', + selected: true, + icons: [], + searchText: identifier, + }; + }) + .filter((option): option is NonNullable => !!option); + } else { + preSelectedOptions = initialAccountIDs + .map((accountID) => { + const participant = personalDetails[accountID]; + if (!participant) { + return undefined; + } + return getSelectedOptionData(participant); + }) + .filter((option): option is NonNullable => !!option); + } setSelectedOptions(preSelectedOptions); // eslint-disable-next-line react-hooks/exhaustive-deps -- this should react only to changes in form data - }, [initialAccountIDs, personalDetails]); + }, [initialAccountIDs, personalDetails, recentAttendees, shouldAllowNameOnlyOptions]); const handleParticipantSelection = useCallback( (option: Option) => { const foundOptionIndex = selectedOptions.findIndex((selectedOption: Option) => { + if (shouldAllowNameOnlyOptions) { + // Match by accountID for real users (excluding DEFAULT_NUMBER_ID which is 0) + if (selectedOption.accountID && selectedOption.accountID !== CONST.DEFAULT_NUMBER_ID && selectedOption.accountID === option?.accountID) { + return true; + } + + // Skip reportID match for default '-1' value (used by name-only attendees) + if (selectedOption.reportID && selectedOption.reportID !== '-1' && selectedOption.reportID === option?.reportID) { + return true; + } + + // Match by login for name-only attendees + if (selectedOption.login && selectedOption.login === option?.login) { + return true; + } + + return false; + } + + // For non-name-only filters, use simple accountID and reportID matching if (selectedOption.accountID && selectedOption.accountID === option?.accountID) { return true; } @@ -241,7 +388,7 @@ function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate}: setSelectedOptions(newSelectedOptions); } }, - [selectedOptions], + [selectedOptions, shouldAllowNameOnlyOptions], ); const footerContent = useMemo( diff --git a/src/components/Search/SearchMultipleSelectionPicker.tsx b/src/components/Search/SearchMultipleSelectionPicker.tsx index 1cae120f349c..e90ea0b2e532 100644 --- a/src/components/Search/SearchMultipleSelectionPicker.tsx +++ b/src/components/Search/SearchMultipleSelectionPicker.tsx @@ -1,7 +1,6 @@ -import React, {useCallback, useEffect, useMemo, useState} from 'react'; -// eslint-disable-next-line no-restricted-imports -import SelectionList from '@components/SelectionListWithSections'; -import MultiSelectListItem from '@components/SelectionListWithSections/MultiSelectListItem'; +import React, {useEffect, useState} from 'react'; +import MultiSelectListItem from '@components/SelectionList/ListItem/MultiSelectListItem'; +import SelectionList from '@components/SelectionList/SelectionListWithSections'; import useDebouncedState from '@hooks/useDebouncedState'; import useLocalize from '@hooks/useLocalize'; import Navigation from '@libs/Navigation/Navigation'; @@ -33,94 +32,89 @@ function SearchMultipleSelectionPicker({items, initiallySelectedItems, pickerTit setSelectedItems(initiallySelectedItems ?? []); }, [initiallySelectedItems]); - const {sections, noResultsFound} = useMemo(() => { - const selectedItemsSection = selectedItems - .filter((item) => item?.name.toLowerCase().includes(debouncedSearchTerm?.toLowerCase())) - .sort((a, b) => sortOptionsWithEmptyValue(a.value.toString(), b.value.toString(), localeCompare)) - .map((item) => ({ - text: item.name, - keyForList: item.name, - isSelected: true, - value: item.value, - })); - const remainingItemsSection = items - .filter( - (item) => - !selectedItems.some((selectedItem) => selectedItem.value.toString() === item.value.toString()) && item?.name?.toLowerCase().includes(debouncedSearchTerm?.toLowerCase()), - ) - .sort((a, b) => sortOptionsWithEmptyValue(a.value.toString(), b.value.toString(), localeCompare)) - .map((item) => ({ - text: item.name, - keyForList: item.name, - isSelected: false, - value: item.value, - })); - const isEmpty = !selectedItemsSection.length && !remainingItemsSection.length; - return { - sections: isEmpty - ? [] - : [ - { - title: undefined, - data: selectedItemsSection, - shouldShow: selectedItemsSection.length > 0, - }, - { - title: pickerTitle, - data: remainingItemsSection, - shouldShow: remainingItemsSection.length > 0, - }, - ], - noResultsFound: isEmpty, - }; - }, [selectedItems, items, pickerTitle, debouncedSearchTerm, localeCompare]); + const selectedItemsSection = selectedItems + .filter((item) => item?.name.toLowerCase().includes(debouncedSearchTerm?.toLowerCase())) + .sort((a, b) => sortOptionsWithEmptyValue(a.value.toString(), b.value.toString(), localeCompare)) + .map((item) => ({ + text: item.name, + keyForList: item.name, + isSelected: true, + value: item.value, + })); - const onSelectItem = useCallback( - (item: Partial) => { - if (!item.text || !item.keyForList || !item.value) { - return; - } - if (item.isSelected) { - setSelectedItems(selectedItems?.filter((selectedItem) => selectedItem.name !== item.keyForList)); - } else { - setSelectedItems([...(selectedItems ?? []), {name: item.text, value: item.value}]); - } - }, - [selectedItems], - ); + const remainingItemsSection = items + .filter( + (item) => + !selectedItems.some((selectedItem) => selectedItem.value.toString() === item.value.toString()) && item?.name?.toLowerCase().includes(debouncedSearchTerm?.toLowerCase()), + ) + .sort((a, b) => sortOptionsWithEmptyValue(a.value.toString(), b.value.toString(), localeCompare)) + .map((item) => ({ + text: item.name, + keyForList: item.name, + isSelected: false, + value: item.value, + })); - const resetChanges = useCallback(() => { + const noResultsFound = !selectedItemsSection.length && !remainingItemsSection.length; + const sections = noResultsFound + ? [] + : [ + { + title: undefined, + data: selectedItemsSection, + sectionIndex: 0, + }, + { + title: pickerTitle, + data: remainingItemsSection, + sectionIndex: 1, + }, + ]; + + const onSelectItem = (item: Partial) => { + if (!item.text || !item.keyForList || !item.value) { + return; + } + if (item.isSelected) { + setSelectedItems(selectedItems?.filter((selectedItem) => selectedItem.name !== item.keyForList)); + } else { + setSelectedItems([...(selectedItems ?? []), {name: item.text, value: item.value}]); + } + }; + + const resetChanges = () => { setSelectedItems([]); - }, []); + }; - const applyChanges = useCallback(() => { + const applyChanges = () => { onSaveSelection(selectedItems.map((item) => item.value).flat()); Navigation.goBack(ROUTES.SEARCH_ADVANCED_FILTERS.getRoute()); - }, [onSaveSelection, selectedItems]); + }; - const footerContent = useMemo( - () => ( - - ), - [resetChanges, applyChanges], - ); + const textInputOptions = { + value: searchTerm, + label: translate('common.search'), + onChangeText: setSearchTerm, + headerMessage: noResultsFound ? translate('common.noResultsFound') : undefined, + }; return ( + } /> ); } diff --git a/src/components/Search/SearchPageHeader/SearchPageHeaderInput.tsx b/src/components/Search/SearchPageHeader/SearchPageHeaderInput.tsx index 0eeaa34f1d2c..d9cb68e26772 100644 --- a/src/components/Search/SearchPageHeader/SearchPageHeaderInput.tsx +++ b/src/components/Search/SearchPageHeader/SearchPageHeaderInput.tsx @@ -3,6 +3,7 @@ import {accountIDSelector} from '@selectors/Session'; import {deepEqual} from 'fast-equals'; import isEmpty from 'lodash/isEmpty'; import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import type {TextInputKeyPressEvent} from 'react-native'; import {View} from 'react-native'; import Animated from 'react-native-reanimated'; import {usePersonalDetails} from '@components/OnyxListItemProvider'; @@ -131,6 +132,15 @@ function SearchPageHeaderInput({queryJSON, searchRouterListVisible, hideSearchRo // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + const handleKeyPress = useCallback((e: TextInputKeyPressEvent) => { + const keyEvent = e as unknown as KeyboardEvent; + + if (keyEvent.key === CONST.KEYBOARD_SHORTCUTS.ESCAPE.shortcutKey && textInputRef.current?.isFocused()) { + keyEvent.preventDefault(); + textInputRef.current.blur(); + } + }, []); + const handleSearchAction = useCallback( (value: string) => { // Skip calling handleSearch on the initial mount @@ -377,6 +387,7 @@ function SearchPageHeaderInput({queryJSON, searchRouterListVisible, hideSearchRo wrapperFocusedStyle={styles.searchAutocompleteInputResultsFocused} autocompleteListRef={listRef} ref={textInputRef} + onKeyPress={handleKeyPress} /> {showPopupButton && ( @@ -457,6 +468,7 @@ function SearchPageHeaderInput({queryJSON, searchRouterListVisible, hideSearchRo ref={textInputRef} selection={selection} substitutionMap={autocompleteSubstitutions} + onKeyPress={handleKeyPress} /> {isAutocompleteListVisible && ( diff --git a/src/components/Search/SearchSingleSelectionPicker.tsx b/src/components/Search/SearchSingleSelectionPicker.tsx index 88c95c804e5c..e821980935fd 100644 --- a/src/components/Search/SearchSingleSelectionPicker.tsx +++ b/src/components/Search/SearchSingleSelectionPicker.tsx @@ -1,7 +1,6 @@ -import React, {useCallback, useEffect, useMemo, useState} from 'react'; -// eslint-disable-next-line no-restricted-imports -import SelectionList from '@components/SelectionListWithSections'; -import SingleSelectListItem from '@components/SelectionListWithSections/SingleSelectListItem'; +import React, {useEffect, useState} from 'react'; +import SingleSelectListItem from '@components/SelectionList/ListItem/SingleSelectListItem'; +import SelectionList from '@components/SelectionList/SelectionListWithSections'; import useDebouncedState from '@hooks/useDebouncedState'; import useLocalize from '@hooks/useLocalize'; import Navigation from '@libs/Navigation/Navigation'; @@ -44,98 +43,93 @@ function SearchSingleSelectionPicker({ setSelectedItem(initiallySelectedItem); }, [initiallySelectedItem]); - const {sections, noResultsFound} = useMemo(() => { - const initiallySelectedItemSection = initiallySelectedItem?.name.toLowerCase().includes(debouncedSearchTerm?.toLowerCase()) - ? [ - { - text: initiallySelectedItem.name, - keyForList: initiallySelectedItem.value, - isSelected: selectedItem?.value === initiallySelectedItem.value, - value: initiallySelectedItem.value, - }, - ] - : []; - const remainingItemsSection = items - .filter((item) => item?.value !== initiallySelectedItem?.value && item?.name?.toLowerCase().includes(debouncedSearchTerm?.toLowerCase())) - .sort((a, b) => sortOptionsWithEmptyValue(a.name.toString(), b.name.toString(), localeCompare)) - .map((item) => ({ - text: item.name, - keyForList: item.value, - isSelected: selectedItem?.value === item.value, - value: item.value, - })); - const isEmpty = !initiallySelectedItemSection.length && !remainingItemsSection.length; - return { - sections: isEmpty - ? [] - : [ - { - title: undefined, - data: initiallySelectedItemSection, - shouldShow: initiallySelectedItemSection.length > 0, - indexOffset: 0, - }, - { - title: pickerTitle, - data: remainingItemsSection, - shouldShow: remainingItemsSection.length > 0, - indexOffset: initiallySelectedItemSection.length, - }, - ], - noResultsFound: isEmpty, - }; - }, [initiallySelectedItem, selectedItem?.value, items, pickerTitle, debouncedSearchTerm, localeCompare]); - - const onSelectItem = useCallback( - (item: Partial) => { - if (!item.text || !item.keyForList || !item.value) { - return; - } - if (shouldAutoSave) { - onSaveSelection(item.isSelected ? '' : item.value); - Navigation.goBack(backToRoute ?? ROUTES.SEARCH_ADVANCED_FILTERS.getRoute()); - return; - } - if (!item.isSelected) { - setSelectedItem({name: item.text, value: item.value}); - } - }, - [shouldAutoSave, backToRoute, onSaveSelection], - ); + const initiallySelectedItemSection = initiallySelectedItem?.name.toLowerCase().includes(debouncedSearchTerm?.toLowerCase()) + ? [ + { + text: initiallySelectedItem.name, + keyForList: initiallySelectedItem.value, + isSelected: selectedItem?.value === initiallySelectedItem.value, + value: initiallySelectedItem.value, + }, + ] + : []; + + const remainingItemsSection = items + .filter((item) => item.value !== initiallySelectedItem?.value && item.name.toLowerCase().includes(debouncedSearchTerm?.toLowerCase())) + .sort((a, b) => sortOptionsWithEmptyValue(a.name.toString(), b.name.toString(), localeCompare)) + .map((item) => ({ + text: item.name, + keyForList: item.value, + isSelected: selectedItem?.value === item.value, + value: item.value, + })); + + const noResultsFound = !initiallySelectedItemSection.length && !remainingItemsSection.length; + + const sections = noResultsFound + ? [] + : [ + { + title: undefined, + data: initiallySelectedItemSection, + sectionIndex: 0, + }, + { + title: pickerTitle, + data: remainingItemsSection, + sectionIndex: 1, + }, + ]; - const resetChanges = useCallback(() => { + const onSelectItem = (item: Partial) => { + if (!item.text || !item.keyForList || !item.value) { + return; + } + if (shouldAutoSave) { + onSaveSelection(item.isSelected ? '' : item.value); + Navigation.goBack(backToRoute ?? ROUTES.SEARCH_ADVANCED_FILTERS.getRoute()); + return; + } + if (!item.isSelected) { + setSelectedItem({name: item.text, value: item.value}); + } + }; + + const resetChanges = () => { setSelectedItem(undefined); - }, []); + }; - const applyChanges = useCallback(() => { + const applyChanges = () => { onSaveSelection(selectedItem?.value); Navigation.goBack(backToRoute ?? ROUTES.SEARCH_ADVANCED_FILTERS.getRoute()); - }, [onSaveSelection, selectedItem?.value, backToRoute]); - - const footerContent = useMemo( - () => ( - - ), - [resetChanges, applyChanges], + }; + + const footerContent = ( + ); + + const textInputOptions = { + value: searchTerm, + label: translate('common.search'), + onChangeText: setSearchTerm, + headerMessage: noResultsFound ? translate('common.noResultsFound') : undefined, + }; + return ( ); } diff --git a/src/components/SelectionList/ListItem/SplitListItem/SplitAmountInput.tsx b/src/components/SelectionList/ListItem/SplitListItem/SplitAmountInput.tsx index 3751c990f277..2785864bbc20 100644 --- a/src/components/SelectionList/ListItem/SplitListItem/SplitAmountInput.tsx +++ b/src/components/SelectionList/ListItem/SplitListItem/SplitAmountInput.tsx @@ -54,6 +54,7 @@ function SplitAmountInput({splitItem, formattedOriginalAmount, contentWidth, onS shouldWrapInputInContainer={false} onFocus={focusHandler} onBlur={onInputBlur} + allowNegativeInput /> ); } diff --git a/src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx b/src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx index 0a9b170b1040..5d29ed7efcb7 100644 --- a/src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx +++ b/src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx @@ -80,7 +80,7 @@ function BaseSelectionListWithSections({ const paddingBottomStyle = !isKeyboardShown && !footerContent && safeAreaPaddingBottomStyle; - const {flattenedData, disabledIndexes, itemsCount, selectedItems, initialFocusedIndex} = useFlattenedSections(sections, initiallyFocusedItemKey); + const {flattenedData, disabledIndexes, itemsCount, selectedItems, initialFocusedIndex, firstFocusableIndex} = useFlattenedSections(sections, initiallyFocusedItemKey); const setHasKeyBeenPressed = () => { if (hasKeyBeenPressed.current) { @@ -90,11 +90,11 @@ function BaseSelectionListWithSections({ }; const scrollToIndex = (index: number) => { - if (index < 0 || index >= flattenedData.length) { + if (index < 0 || index >= flattenedData.length || !listRef.current) { return; } const item = flattenedData.at(index); - if (!listRef.current || !item || getItemType(item) === CONST.SECTION_LIST_ITEM_TYPE.HEADER) { + if (!item) { return; } try { @@ -140,8 +140,14 @@ function BaseSelectionListWithSections({ if (!isScreenFocused) { return; } - if (canSelectMultiple && shouldShowTextInput) { - textInputOptions?.onChangeText?.(''); + if (canSelectMultiple) { + if (sections.length > 1 && !isItemSelected(item)) { + scrollToIndex(0); + } + + if (shouldShowTextInput) { + textInputOptions?.onChangeText?.(''); + } } if (shouldUpdateFocusedIndex && typeof indexToFocus === 'number') { setFocusedIndex(indexToFocus); @@ -208,6 +214,7 @@ function BaseSelectionListWithSections({ shouldUpdateFocusedIndex, scrollToIndex, setFocusedIndex, + firstFocusableIndex, }); const textInputComponent = () => { diff --git a/src/components/SelectionList/hooks/useFlattenedSections.ts b/src/components/SelectionList/hooks/useFlattenedSections.ts index 52a13cfef1a3..868e4774701f 100644 --- a/src/components/SelectionList/hooks/useFlattenedSections.ts +++ b/src/components/SelectionList/hooks/useFlattenedSections.ts @@ -31,6 +31,9 @@ type UseFlattenedSectionsResult = { /** Index of initially focused item in flattenedData, or -1 if none */ initialFocusedIndex: number; + + /** Index of the first focusable (non-header) item in flattenedData. Returns 0 if no items exist. */ + firstFocusableIndex: number; }; /** @@ -43,6 +46,7 @@ function useFlattenedSections(sections: Array(sections: Array; data.push(itemData); + if (firstNonHeaderIndex === -1) { + firstNonHeaderIndex = currentIndex; + } + if (item.keyForList === initiallyFocusedItemKey && focusedIndex === -1) { focusedIndex = currentIndex; } @@ -89,6 +97,7 @@ function useFlattenedSections(sections: Array = { /** Function to set the focused index */ setFocusedIndex: (index: number) => void; + + /** The first focusable index in the list (useful when index 0 is a header). Defaults to 0. */ + firstFocusableIndex?: number; }; /** @@ -44,6 +47,7 @@ function useSearchFocusSync({ shouldUpdateFocusedIndex, scrollToIndex, setFocusedIndex, + firstFocusableIndex = 0, }: UseSearchFocusSyncParams) { const prevSearchValue = usePrevious(searchValue); const prevSelectedOptionsCount = usePrevious(selectedOptionsCount); @@ -76,12 +80,18 @@ function useSearchFocusSync({ // Remove focus (set focused index to -1) if: // 1. If the search is idle or // 2. If the user is just toggling options without changing the list content - // Otherwise (e.g. when filtering/typing), focus on the first item (0) + // Otherwise (e.g. when filtering/typing), scroll to top and focus on the first focusable item const isSearchIdle = !prevSearchValue && !searchValue; - const newSelectedIndex = isSearchIdle || (selectedOptionsChanged && prevItemsLength === data.length) ? -1 : 0; + const shouldResetFocus = isSearchIdle || (selectedOptionsChanged && prevItemsLength === data.length); + + if (shouldResetFocus) { + setFocusedIndex(-1); + return; + } - scrollToIndex(newSelectedIndex); - setFocusedIndex(newSelectedIndex); + // Scroll to top of list and focus on first focusable item (not header) + scrollToIndex(0); + setFocusedIndex(firstFocusableIndex); }, [ canSelectMultiple, data, @@ -94,6 +104,7 @@ function useSearchFocusSync({ shouldUpdateFocusedIndex, searchValue, isItemSelected, + firstFocusableIndex, ]); } diff --git a/src/components/SelectionListWithSections/Search/UserSelectionListItem.tsx b/src/components/SelectionListWithSections/Search/UserSelectionListItem.tsx index 52266d391e92..1a40d592470b 100644 --- a/src/components/SelectionListWithSections/Search/UserSelectionListItem.tsx +++ b/src/components/SelectionListWithSections/Search/UserSelectionListItem.tsx @@ -61,11 +61,17 @@ function UserSelectionListItem({ }, [currentUserPersonalDetails.login, item.login]); const userDisplayName = useMemo(() => { - return getDisplayNameForParticipant({ - accountID: item.accountID ?? CONST.DEFAULT_NUMBER_ID, - formatPhoneNumber, - }); - }, [formatPhoneNumber, item.accountID]); + /* eslint-disable @typescript-eslint/prefer-nullish-coalescing -- need || to handle empty string from getDisplayNameForParticipant */ + return ( + getDisplayNameForParticipant({ + accountID: item.accountID ?? CONST.DEFAULT_NUMBER_ID, + formatPhoneNumber, + }) || + item.text || + '' + ); + /* eslint-enable @typescript-eslint/prefer-nullish-coalescing */ + }, [formatPhoneNumber, item.accountID, item.text]); return ( ({ transactions.map((transaction) => transaction.transactionID), [transactions]); + const filteredSelectedTransactionIDs = useMemo(() => selectedTransactionIDs.filter((id) => transactionIDs.includes(id)), [selectedTransactionIDs, transactionIDs]); + useEffect(() => { + if (filteredSelectedTransactionIDs.length === selectedTransactionIDs.length) { + return; + } + setSelectedTransactions(filteredSelectedTransactionIDs); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [filteredSelectedTransactionIDs]); +} + +export default useFilterSelectedTransactions; diff --git a/src/hooks/usePaginatedReportActions.ts b/src/hooks/usePaginatedReportActions.ts index fb3f9b7834c0..c49dc1af84fa 100644 --- a/src/hooks/usePaginatedReportActions.ts +++ b/src/hooks/usePaginatedReportActions.ts @@ -27,9 +27,9 @@ function usePaginatedReportActions(reportID: string | undefined, reportActionID? const getSortedAllReportActionsSelector = useCallback( (allReportActions: OnyxEntry): ReportAction[] => { - return getSortedReportActionsForDisplay(allReportActions, hasWriteAccess, true, undefined, nonEmptyStringReportID); + return getSortedReportActionsForDisplay(allReportActions, hasWriteAccess, true); }, - [hasWriteAccess, nonEmptyStringReportID], + [hasWriteAccess], ); const [sortedAllReportActions] = useOnyx( diff --git a/src/hooks/useSearchHighlightAndScroll.ts b/src/hooks/useSearchHighlightAndScroll.ts index a66f5e43ef60..300839312989 100644 --- a/src/hooks/useSearchHighlightAndScroll.ts +++ b/src/hooks/useSearchHighlightAndScroll.ts @@ -1,16 +1,19 @@ import {useIsFocused} from '@react-navigation/native'; -import {useEffect, useRef, useState} from 'react'; +import {useCallback, useEffect, useRef, useState} from 'react'; import {InteractionManager} from 'react-native'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import type {SearchQueryJSON} from '@components/Search/types'; import type {SearchListItem, SelectionListHandle, TransactionGroupListItemType, TransactionListItemType} from '@components/SelectionListWithSections/types'; import {search} from '@libs/actions/Search'; +import {mergeTransactionIdsHighlightOnSearchRoute} from '@libs/actions/Transaction'; import {isReportActionEntry} from '@libs/SearchUIUtils'; import type {SearchKey} from '@libs/SearchUIUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {ReportActions, SearchResults, Transaction} from '@src/types/onyx'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; import useNetwork from './useNetwork'; +import useOnyx from './useOnyx'; import usePrevious from './usePrevious'; type UseSearchHighlightAndScroll = { @@ -53,6 +56,12 @@ function useSearchHighlightAndScroll({ const initializedRef = useRef(false); const hasPendingSearchRef = useRef(false); const isChat = queryJSON.type === CONST.SEARCH.DATA_TYPES.CHAT; + + const transactionIDsToHighlightSelector = useCallback((allTransactionIDs: OnyxEntry>>) => allTransactionIDs?.[queryJSON.type], [queryJSON.type]); + const [transactionIDsToHighlight] = useOnyx(ONYXKEYS.TRANSACTION_IDS_HIGHLIGHT_ON_SEARCH_ROUTE, { + canBeMissing: true, + selector: transactionIDsToHighlightSelector, + }); const searchResultsData = searchResults?.data; const prevTransactionsIDs = Object.keys(previousTransactions ?? {}); @@ -195,11 +204,20 @@ function useSearchHighlightAndScroll({ } else { const previousTransactionIDs = extractTransactionIDsFromSearchResults(previousSearchResults); const currentTransactionIDs = extractTransactionIDsFromSearchResults(searchResults.data); + const manualHighlightTransactionIDs = new Set(Object.keys(transactionIDsToHighlight ?? {}).filter((id) => !!transactionIDsToHighlight?.[id])); // Find new transaction IDs that are not in the previousTransactionIDs and not already highlighted - const newTransactionIDs = currentTransactionIDs.filter((id) => !previousTransactionIDs.includes(id) && !highlightedIDs.current.has(id)); + const newTransactionIDs = currentTransactionIDs.filter((id) => { + if (manualHighlightTransactionIDs.has(id)) { + return true; + } + if (!triggeredByHookRef.current || !hasNewItemsRef.current) { + return false; + } + return !previousTransactionIDs.includes(id) && !highlightedIDs.current.has(id); + }); - if (!triggeredByHookRef.current || newTransactionIDs.length === 0 || !hasNewItemsRef.current) { + if (newTransactionIDs.length === 0) { return; } @@ -211,7 +229,41 @@ function useSearchHighlightAndScroll({ } setNewSearchResultKeys(newKeys); } - }, [searchResults?.data, previousSearchResults, isChat]); + }, [searchResults?.data, previousSearchResults, isChat, transactionIDsToHighlight]); + + // Reset transactionIDsToHighlight after they have been highlighted + useEffect(() => { + if (isEmptyObject(transactionIDsToHighlight) || newSearchResultKeys === null) { + return; + } + + const highlightedTransactionIDs = Object.keys(transactionIDsToHighlight).filter( + (id) => transactionIDsToHighlight[id] && newSearchResultKeys?.has(`${ONYXKEYS.COLLECTION.TRANSACTION}${id}`), + ); + + // We need to use requestAnimationFrame here to ensure that setTimeout actually starts + // only after the user has navigated to the "Reports > Expenses" page. + // Otherwise, there is still a chance we might miss the timing because setTimeout runs too early, + // causing the highlight not to appear. + let timer: NodeJS.Timeout; + const animation = requestAnimationFrame(() => { + timer = setTimeout(() => { + mergeTransactionIdsHighlightOnSearchRoute(queryJSON.type, Object.fromEntries(highlightedTransactionIDs.map((id) => [id, false]))); + }, CONST.ANIMATED_HIGHLIGHT_START_DURATION); + }); + return () => { + clearTimeout(timer); + cancelAnimationFrame(animation); + }; + }, [transactionIDsToHighlight, queryJSON.type, newSearchResultKeys]); + + // Remove transactionIDsToHighlight when the user leaves the current search type + useEffect( + () => () => { + mergeTransactionIdsHighlightOnSearchRoute(queryJSON.type, null); + }, + [queryJSON.type], + ); // Reset newSearchResultKey after it's been used useEffect(() => { diff --git a/src/hooks/useSearchSelector.base.ts b/src/hooks/useSearchSelector.base.ts index 5cc956993bd8..7e564704f28e 100644 --- a/src/hooks/useSearchSelector.base.ts +++ b/src/hooks/useSearchSelector.base.ts @@ -167,7 +167,6 @@ function useSearchSelectorBase({ const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {canBeMissing: true}); const [draftComments] = useOnyx(ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT, {canBeMissing: true}); const [nvpDismissedProductTraining] = useOnyx(ONYXKEYS.NVP_DISMISSED_PRODUCT_TRAINING, {canBeMissing: true}); - const [visibleReportActionsData] = useOnyx(ONYXKEYS.DERIVED.VISIBLE_REPORT_ACTIONS, {canBeMissing: true}); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const currentUserAccountID = currentUserPersonalDetails.accountID; const currentUserEmail = currentUserPersonalDetails.email ?? ''; @@ -183,6 +182,7 @@ function useSearchSelectorBase({ const computedSearchTerm = useMemo(() => { return getSearchValueForPhoneOrEmail(debouncedSearchTerm, countryCode); }, [debouncedSearchTerm, countryCode]); + const trimmedSearchInput = debouncedSearchTerm.trim(); const baseOptions = useMemo(() => { if (!areOptionsInitialized) { @@ -203,7 +203,6 @@ function useSearchSelectorBase({ includeUserToInvite, countryCode, loginList, - visibleReportActionsData, currentUserAccountID, currentUserEmail, }); @@ -217,6 +216,7 @@ function useSearchSelectorBase({ maxElements: maxResults, maxRecentReportElements: maxRecentReportsToShow, searchString: computedSearchTerm, + searchInputValue: trimmedSearchInput, includeUserToInvite, personalDetails, }); @@ -225,6 +225,7 @@ function useSearchSelectorBase({ ...getValidOptionsConfig, betas: betas ?? [], searchString: computedSearchTerm, + searchInputValue: trimmedSearchInput, maxElements: maxResults, maxRecentReportElements: maxRecentReportsToShow, includeUserToInvite, @@ -250,6 +251,7 @@ function useSearchSelectorBase({ includeThreads: true, includeReadOnly: false, searchString: computedSearchTerm, + searchInputValue: trimmedSearchInput, maxElements: maxResults, includeUserToInvite, personalDetails, @@ -271,6 +273,7 @@ function useSearchSelectorBase({ includeOwnedWorkspaceChats: true, includeSelfDM: true, searchString: computedSearchTerm, + searchInputValue: trimmedSearchInput, maxElements: maxResults, includeUserToInvite, personalDetails, @@ -287,6 +290,7 @@ function useSearchSelectorBase({ maxElements: maxResults, maxRecentReportElements: maxRecentReportsToShow, searchString: computedSearchTerm, + searchInputValue: trimmedSearchInput, includeUserToInvite, includeCurrentUser, shouldAcceptName: true, @@ -299,7 +303,6 @@ function useSearchSelectorBase({ areOptionsInitialized, searchContext, optionsWithContacts, - allPolicies, draftComments, nvpDismissedProductTraining, betas, @@ -314,10 +317,10 @@ function useSearchSelectorBase({ getValidOptionsConfig, selectedOptions, includeCurrentUser, - visibleReportActionsData, currentUserAccountID, currentUserEmail, personalDetails, + trimmedSearchInput, ]); const isOptionSelected = useMemo(() => { diff --git a/src/hooks/useSubPage/index.tsx b/src/hooks/useSubPage/index.tsx index 21a030affeec..58506007a4df 100644 --- a/src/hooks/useSubPage/index.tsx +++ b/src/hooks/useSubPage/index.tsx @@ -1,10 +1,12 @@ import {useRoute} from '@react-navigation/native'; import type {ComponentType} from 'react'; -import {useEffect, useRef} from 'react'; +import {useEffect} from 'react'; import Navigation from '@libs/Navigation/Navigation'; import {findLastPageIndex, findPageIndex} from '@libs/SubPageUtils'; import type {SubPageProps, UseSubPageProps} from './types'; +const AMOUNT_OF_FRAMES_TO_WAIT_FOR = 20; + /** * @param pages - array of objects with pageName and component to display in each page * @param onFinished - callback triggered after finishing the last page @@ -20,18 +22,28 @@ export default function useSubPage({pages, onFinish const isEditing = params?.action === 'edit'; const startPageName = pages.at(startFrom)?.pageName; - const hasInitialized = useRef(false); + const isRedirecting = !urlPageName && !!startPageName; useEffect(() => { - if (hasInitialized.current) { + if (!isRedirecting) { return; } - hasInitialized.current = true; - if (!urlPageName && startPageName) { - Navigation.navigate(buildRoute(startPageName), {forceReplace: true}); - } - }, [urlPageName, startPageName, buildRoute]); + let requestID: number; + const waitFrames = (framesLeft: number) => { + if (framesLeft <= 0) { + Navigation.navigate(buildRoute(startPageName), {forceReplace: true}); + return; + } + requestID = requestAnimationFrame(() => waitFrames(framesLeft - 1)); + }; + + requestID = requestAnimationFrame(() => waitFrames(AMOUNT_OF_FRAMES_TO_WAIT_FOR)); + + return () => { + cancelAnimationFrame(requestID); + }; + }, [isRedirecting, startPageName, buildRoute]); const currentPageName = urlPageName ?? startPageName ?? pages.at(0)?.pageName; const pageIndex = findPageIndex(pages, currentPageName); @@ -114,5 +126,6 @@ export default function useSubPage({pages, onFinish lastPageIndex, moveTo, resetToPage, + isRedirecting, }; } diff --git a/src/languages/de.ts b/src/languages/de.ts index efade9708542..0ca76959ddd2 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -2475,7 +2475,7 @@ ${amount} für ${merchant} – ${date}`, title: 'Regel hinzufügen', expenseContains: 'Wenn Ausgabe enthält:', applyUpdates: 'Wenden Sie dann diese Aktualisierungen an:', - merchantHint: 'Gib * ein, um eine Regel zu erstellen, die für alle Händler gilt', + merchantHint: 'Gib . ein, um eine Regel zu erstellen, die für alle Händler gilt', addToReport: 'Zu einem Bericht mit dem Namen hinzufügen', createReport: 'Bericht bei Bedarf erstellen', applyToExistingExpenses: 'Auf passende vorhandene Spesen anwenden', diff --git a/src/languages/en.ts b/src/languages/en.ts index 68a673ed08b6..4f98d69bac10 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -2503,7 +2503,7 @@ const translations = { title: 'Add rule', expenseContains: 'If expense contains:', applyUpdates: 'Then apply these updates:', - merchantHint: 'Type * to create a rule that applies to all merchants', + merchantHint: 'Type . to create a rule that applies to all merchants', addToReport: 'Add to a report named', createReport: 'Create report if necessary', applyToExistingExpenses: 'Apply to existing matching expenses', diff --git a/src/languages/es.ts b/src/languages/es.ts index 39a25d7eb8e0..88a441147bea 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -2233,7 +2233,7 @@ ${amount} para ${merchant} - ${date}`, title: 'Añadir regla', expenseContains: 'Si el gasto contiene:', applyUpdates: 'Entonces aplica estas actualizaciones:', - merchantHint: 'Escribe * para crear una regla que se aplique a todos los comercios', + merchantHint: 'Escribe . para crear una regla que se aplique a todos los comercios', addToReport: 'Añadir a un informe llamado', createReport: 'Crear informe si es necesario', applyToExistingExpenses: 'Aplicar a gastos existentes que coincidan', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 1694dbd0288f..dac42b804547 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -2480,7 +2480,7 @@ ${amount} pour ${merchant} - ${date}`, title: 'Ajouter une règle', expenseContains: 'Si la dépense contient :', applyUpdates: 'Appliquez ensuite ces mises à jour :', - merchantHint: 'Tapez * pour créer une règle qui s’applique à tous les commerçants', + merchantHint: 'Tapez . pour créer une règle qui s’applique à tous les commerçants', addToReport: 'Ajouter à un rapport nommé', createReport: 'Créer un rapport si nécessaire', applyToExistingExpenses: 'Appliquer aux dépenses correspondantes existantes', diff --git a/src/languages/it.ts b/src/languages/it.ts index bf2876dcc6f5..7a2dfee962e3 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -2472,7 +2472,7 @@ ${amount} per ${merchant} - ${date}`, title: 'Aggiungi regola', expenseContains: 'Se la spesa contiene:', applyUpdates: 'Quindi applica questi aggiornamenti:', - merchantHint: 'Digita * per creare una regola che si applica a tutti gli esercenti', + merchantHint: 'Digita . per creare una regola che si applica a tutti gli esercenti', addToReport: 'Aggiungi a un report chiamato', createReport: 'Crea report se necessario', applyToExistingExpenses: 'Applica alle spese corrispondenti esistenti', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 7aa3b741aea4..78d5943b7d8e 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -2460,7 +2460,7 @@ ${merchant} への ${amount}(${date})`, title: 'ルールを追加', expenseContains: '経費に以下が含まれる場合:', applyUpdates: '次に、これらの更新を適用します。', - merchantHint: 'すべての加盟店に適用されるルールを作成するには * を入力してください', + merchantHint: 'すべての加盟店に適用されるルールを作成するには . を入力してください', addToReport: '「…という名前のレポートに追加」', createReport: '必要に応じてレポートを作成', applyToExistingExpenses: '既存の一致する経費に適用', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 65721887d011..a15f291db6eb 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -2470,7 +2470,7 @@ ${amount} voor ${merchant} - ${date}`, title: 'Regel toevoegen', expenseContains: 'Als de uitgave bevat:', applyUpdates: 'Breng vervolgens deze updates aan:', - merchantHint: 'Typ * om een regel te maken die voor alle leveranciers geldt', + merchantHint: 'Typ . om een regel te maken die voor alle leveranciers geldt', addToReport: 'Toevoegen aan een rapport met de naam', createReport: 'Maak rapport indien nodig', applyToExistingExpenses: 'Toepassen op bestaande overeenkomende onkosten', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 9e3bba23766f..3e65d6a17188 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -2463,7 +2463,7 @@ ${amount} dla ${merchant} - ${date}`, title: 'Dodaj regułę', expenseContains: 'Jeśli wydatek zawiera:', applyUpdates: 'Następnie zastosuj te aktualizacje:', - merchantHint: 'Wpisz * , aby utworzyć regułę, która ma zastosowanie do wszystkich sprzedawców', + merchantHint: 'Wpisz . , aby utworzyć regułę, która ma zastosowanie do wszystkich sprzedawców', addToReport: 'Dodaj do raportu o nazwie', createReport: 'Utwórz raport, jeśli to konieczne', applyToExistingExpenses: 'Zastosuj do istniejących pasujących wydatków', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index a7649d9abd45..95086b70b808 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -2463,7 +2463,7 @@ ${amount} para ${merchant} - ${date}`, title: 'Adicionar regra', expenseContains: 'Se a despesa contiver:', applyUpdates: 'Em seguida, aplique estas atualizações:', - merchantHint: 'Digite * para criar uma regra que se aplique a todos os estabelecimentos', + merchantHint: 'Digite . para criar uma regra que se aplique a todos os estabelecimentos', addToReport: 'Adicionar a um relatório chamado', createReport: 'Criar relatório se necessário', applyToExistingExpenses: 'Aplicar às despesas correspondentes existentes', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 837442ab490f..7ec4bfb66983 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -2426,7 +2426,7 @@ ${amount},商户:${merchant} - ${date}`, title: '添加规则', expenseContains: '如果报销包含:', applyUpdates: '然后应用这些更新:', - merchantHint: '输入 * 以创建适用于所有商家的规则', + merchantHint: '输入 . 以创建适用于所有商家的规则', addToReport: '添加到名为', createReport: '如有必要则创建报表', applyToExistingExpenses: '应用到现有匹配报销', diff --git a/src/libs/DebugUtils.ts b/src/libs/DebugUtils.ts index 8d7af62b6891..183ff5891626 100644 --- a/src/libs/DebugUtils.ts +++ b/src/libs/DebugUtils.ts @@ -976,6 +976,7 @@ function validateTransactionDraftProperty(key: keyof Transaction, value: string) case 'reimbursable': case 'participantsAutoAssigned': case 'isFromGlobalCreate': + case 'isFromFloatingActionButton': case 'hasEReceipt': case 'shouldShowOriginalAmount': case 'managedCard': @@ -1085,6 +1086,7 @@ function validateTransactionDraftProperty(key: keyof Transaction, value: string) tag: CONST.RED_BRICK_ROAD_PENDING_ACTION, transactionType: CONST.RED_BRICK_ROAD_PENDING_ACTION, isFromGlobalCreate: CONST.RED_BRICK_ROAD_PENDING_ACTION, + isFromFloatingActionButton: CONST.RED_BRICK_ROAD_PENDING_ACTION, taxRate: CONST.RED_BRICK_ROAD_PENDING_ACTION, parentTransactionID: CONST.RED_BRICK_ROAD_PENDING_ACTION, reimbursable: CONST.RED_BRICK_ROAD_PENDING_ACTION, diff --git a/src/libs/MultifactorAuthentication/Biometrics/helpers.ts b/src/libs/MultifactorAuthentication/Biometrics/helpers.ts index 50d8246e7dee..d463118c7e96 100644 --- a/src/libs/MultifactorAuthentication/Biometrics/helpers.ts +++ b/src/libs/MultifactorAuthentication/Biometrics/helpers.ts @@ -15,7 +15,7 @@ const findMessageInSource = (source: ParseHTTPSource[keyof ParseHTTPSource], mes } const sourceEntries = Object.entries(source) as Entries; - const [, value] = sourceEntries.find(([, predefinedMessage]) => predefinedMessage === message) ?? []; + const [, value] = sourceEntries.find(([, predefinedMessage]) => message.endsWith(predefinedMessage)) ?? []; return value ?? VALUES.REASON.BACKEND.UNKNOWN_RESPONSE; }; diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index d66c3adc9dcd..ec9b354883b4 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -86,6 +86,9 @@ const OPTIONS_PER_SCREEN: Partial [SCREENS.TRAVEL.WORKSPACE_ADDRESS]: { animationTypeForReplace: 'push', }, + [SCREENS.MISSING_PERSONAL_DETAILS]: { + animationTypeForReplace: 'push', + }, [SCREENS.MULTIFACTOR_AUTHENTICATION.MAGIC_CODE]: { animationTypeForReplace: 'push', diff --git a/src/libs/Navigation/Navigation.ts b/src/libs/Navigation/Navigation.ts index 47f38ca4df3c..35c74372fcf5 100644 --- a/src/libs/Navigation/Navigation.ts +++ b/src/libs/Navigation/Navigation.ts @@ -23,6 +23,7 @@ import ROUTES from '@src/ROUTES'; import SCREENS, {PROTECTED_SCREENS} from '@src/SCREENS'; import type {Account, SidePanel} from '@src/types/onyx'; import getInitialSplitNavigatorState from './AppNavigator/createSplitNavigator/getInitialSplitNavigatorState'; +import getSearchTopmostReportParams from './getSearchTopmostReportParams'; import originalCloseRHPFlow from './helpers/closeRHPFlow'; import getStateFromPath from './helpers/getStateFromPath'; import getTopmostReportParams from './helpers/getTopmostReportParams'; @@ -171,6 +172,11 @@ function canNavigate(methodName: string, params: CanNavigateParams = {}): boolea */ const getTopmostReportId = (state = navigationRef.getState()) => getTopmostReportParams(state)?.reportID; +/** + * Extracts from the topmost report its id which also include the RHP report and search money request report. + */ +const getSearchTopmostReportId = (state = navigationRef.getRootState()) => getSearchTopmostReportParams(state)?.reportID; + /** * Extracts from the topmost report its action id. */ @@ -936,6 +942,7 @@ export default { dismissToPreviousRHP, dismissToSuperWideRHP, getTopmostSearchReportID, + getSearchTopmostReportId, getTopmostSuperWideRHPReportParams, getTopmostSuperWideRHPReportID, getTopmostSearchReportRouteParams, diff --git a/src/libs/Navigation/getSearchTopmostReportParams.ts b/src/libs/Navigation/getSearchTopmostReportParams.ts new file mode 100644 index 000000000000..f4d14c4ca609 --- /dev/null +++ b/src/libs/Navigation/getSearchTopmostReportParams.ts @@ -0,0 +1,62 @@ +import type {NavigationState, PartialState} from '@react-navigation/native'; +import NAVIGATORS from '@src/NAVIGATORS'; +import SCREENS from '@src/SCREENS'; +import type {ReportsSplitNavigatorParamList, RightModalNavigatorParamList, RootNavigatorParamList} from './types'; + +// This function is in a separate file than Navigation.ts to avoid cyclic dependency. + +type State = NavigationState | NavigationState | PartialState; + +function getReportRHPParams(state: State): RightModalNavigatorParamList[typeof SCREENS.RIGHT_MODAL.SEARCH_REPORT] | undefined { + const lastRoute = state?.routes?.at(-1); + if (!lastRoute || lastRoute.name !== NAVIGATORS.RIGHT_MODAL_NAVIGATOR) { + return; + } + const topmostRHPReport = lastRoute.state?.routes?.findLast( + (route) => route?.name === SCREENS.RIGHT_MODAL.SEARCH_REPORT || route?.name === SCREENS.RIGHT_MODAL.EXPENSE_REPORT || route?.name === SCREENS.RIGHT_MODAL.SEARCH_MONEY_REQUEST_REPORT, + ); + if (!topmostRHPReport) { + return; + } + return topmostRHPReport?.params as RightModalNavigatorParamList[typeof SCREENS.RIGHT_MODAL.SEARCH_REPORT]; +} + +/** + * Find the last visited report screen (Inbox report/RHP report/Search money request report) in the navigation state and get its params. + * + * @param state - The react-navigation state + * @returns - It's possible that there is no report screen + */ + +function getSearchTopmostReportParams(state: State): ReportsSplitNavigatorParamList[typeof SCREENS.REPORT] | undefined { + if (!state) { + return; + } + + const RHPReportParams = getReportRHPParams(state); + if (RHPReportParams) { + return RHPReportParams; + } + + const topmostReportsSplitNavigator = state.routes?.findLast((route) => route.name === NAVIGATORS.REPORTS_SPLIT_NAVIGATOR || route.name === NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR); + + if (!topmostReportsSplitNavigator) { + return; + } + + let topmostReport; + + if (topmostReportsSplitNavigator.name === NAVIGATORS.REPORTS_SPLIT_NAVIGATOR) { + topmostReport = topmostReportsSplitNavigator.state?.routes.findLast((route) => route.name === SCREENS.REPORT); + } else { + topmostReport = topmostReportsSplitNavigator.state?.routes.findLast((route) => route.name === SCREENS.SEARCH.MONEY_REQUEST_REPORT); + } + + if (!topmostReport) { + return; + } + + return topmostReport?.params as ReportsSplitNavigatorParamList[typeof SCREENS.REPORT]; +} + +export default getSearchTopmostReportParams; diff --git a/src/libs/OptionsListUtils/index.ts b/src/libs/OptionsListUtils/index.ts index 4f9c2bd85633..357ed301263b 100644 --- a/src/libs/OptionsListUtils/index.ts +++ b/src/libs/OptionsListUtils/index.ts @@ -45,7 +45,6 @@ import { getInvoiceCompanyWebsiteUpdateMessage, getIOUReportIDFromReportActionPreview, getJoinRequestMessage, - getLastVisibleAction, getLastVisibleMessage, getMarkedReimbursedMessage, getMentionedAccountIDsFromAction, @@ -87,11 +86,12 @@ import { isReimbursementDeQueuedOrCanceledAction, isReimbursementQueuedAction, isRenamedAction, - isReportActionVisible, isReportPreviewAction, isTaskAction, isThreadParentMessage, isUnapprovedAction, + isWhisperAction, + shouldReportActionBeVisible, withDEWRoutedActionsArray, } from '@libs/ReportActionsUtils'; import {computeReportName} from '@libs/ReportNameUtils'; @@ -166,7 +166,6 @@ import type { ReportAttributesDerivedValue, ReportMetadata, ReportNameValuePairs, - VisibleReportActionsDerivedValue, } from '@src/types/onyx'; import type {Attendee, Participant} from '@src/types/onyx/IOU'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; @@ -242,6 +241,7 @@ Onyx.connect({ const lastReportActions: ReportActions = {}; const allSortedReportActions: Record = {}; let allReportActions: OnyxCollection; +const lastVisibleReportActions: ReportActions = {}; Onyx.connect({ key: ONYXKEYS.COLLECTION.REPORT_ACTIONS, waitForCollectionCallback: true, @@ -252,7 +252,7 @@ Onyx.connect({ allReportActions = actions ?? {}; - // Iterate over the report actions to build the sorted report actions objects + // Iterate over the report actions to build the sorted and lastVisible report actions objects for (const reportActions of Object.entries(allReportActions)) { const reportID = reportActions[0].split('_').at(1); if (!reportID) { @@ -271,7 +271,6 @@ Onyx.connect({ if (transactionThreadReportID) { const transactionThreadReportActionsArray = Object.values(actions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReportID}`] ?? {}); sortedReportActions = getCombinedReportActions(sortedReportActions, transactionThreadReportID, transactionThreadReportActionsArray, reportID); - allSortedReportActions[reportID] = sortedReportActions; } const firstReportAction = sortedReportActions.at(0); @@ -280,6 +279,26 @@ Onyx.connect({ } else { lastReportActions[reportID] = firstReportAction; } + + const reportNameValuePairs = allReportNameValuePairsOnyxConnect?.[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${reportID}`]; + const isReportArchived = !!reportNameValuePairs?.private_isArchived; + const isWriteActionAllowed = canUserPerformWriteAction(report, isReportArchived); + + // The report is only visible if it is the last action not deleted that + // does not match a closed or created state. + const reportActionsForDisplay = sortedReportActions.filter( + (reportAction, actionKey) => + (!(isWhisperAction(reportAction) && !isReportPreviewAction(reportAction) && !isMoneyRequestAction(reportAction)) || isActionableMentionWhisper(reportAction)) && + shouldReportActionBeVisible(reportAction, actionKey, isWriteActionAllowed) && + reportAction.actionName !== CONST.REPORT.ACTIONS.TYPE.CREATED && + reportAction.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, + ); + const reportActionForDisplay = reportActionsForDisplay.at(0); + if (!reportActionForDisplay) { + delete lastVisibleReportActions[reportID]; + continue; + } + lastVisibleReportActions[reportID] = reportActionForDisplay; } }, }); @@ -407,8 +426,8 @@ function shouldShowLastActorDisplayName( lastAction: OnyxEntry, currentUserAccountIDParam: number, ) { - // Use lastAction directly instead of getLastVisibleReportAction to avoid using stale cache data - const lastReportAction = lastAction; + const reportID = report?.reportID; + const lastReportAction = (reportID ? lastVisibleReportActions[reportID] : undefined) ?? lastAction; // Use report.lastActionType as fallback when report actions aren't loaded yet (e.g., on cold start) const lastActionName = lastReportAction?.actionName ?? report?.lastActionType; @@ -443,35 +462,27 @@ function getAlternateText( isReportArchived: boolean | undefined, currentUserAccountID: number, lastActorDetails: Partial | null = {}, - visibleReportActionsData: VisibleReportActionsDerivedValue = {}, - translate?: LocalizedTranslate, ) { const report = getReportOrDraftReport(option.reportID); const isAdminRoom = reportUtilsIsAdminRoom(report); const isAnnounceRoom = reportUtilsIsAnnounceRoom(report); const isGroupChat = reportUtilsIsGroupChat(report); const isExpenseThread = isMoneyRequest(report); - // eslint-disable-next-line @typescript-eslint/no-deprecated - const translateFn = translate ?? translateLocal; const formattedLastMessageText = formatReportLastMessageText(Parser.htmlToText(option.lastMessageText ?? '')) || - getLastMessageTextForReport({ - translate: translateFn, - report, - lastActorDetails, - isReportArchived, - visibleReportActionsDataParam: visibleReportActionsData, - currentUserAccountID, - }); + // eslint-disable-next-line @typescript-eslint/no-deprecated + getLastMessageTextForReport({translate: translateLocal, report, lastActorDetails, isReportArchived, currentUserAccountID}); const reportPrefix = getReportSubtitlePrefix(report); const formattedLastMessageTextWithPrefix = reportPrefix + formattedLastMessageText; if (isExpenseThread || option.isMoneyRequestReport) { - return showChatPreviewLine && formattedLastMessageText ? formattedLastMessageTextWithPrefix : translateFn('iou.expense'); + // eslint-disable-next-line @typescript-eslint/no-deprecated + return showChatPreviewLine && formattedLastMessageText ? formattedLastMessageTextWithPrefix : translateLocal('iou.expense'); } if (option.isThread) { - return showChatPreviewLine && formattedLastMessageText ? formattedLastMessageTextWithPrefix : translateFn('threads.thread'); + // eslint-disable-next-line @typescript-eslint/no-deprecated + return showChatPreviewLine && formattedLastMessageText ? formattedLastMessageTextWithPrefix : translateLocal('threads.thread'); } if (option.isChatRoom && !isAdminRoom && !isAnnounceRoom) { @@ -483,11 +494,13 @@ function getAlternateText( } if (option.isTaskReport) { - return showChatPreviewLine && formattedLastMessageText ? formattedLastMessageTextWithPrefix : translateFn('task.task'); + // eslint-disable-next-line @typescript-eslint/no-deprecated + return showChatPreviewLine && formattedLastMessageText ? formattedLastMessageTextWithPrefix : translateLocal('task.task'); } if (isGroupChat) { - return showChatPreviewLine && formattedLastMessageText ? formattedLastMessageTextWithPrefix : translateFn('common.group'); + // eslint-disable-next-line @typescript-eslint/no-deprecated + return showChatPreviewLine && formattedLastMessageText ? formattedLastMessageTextWithPrefix : translateLocal('common.group'); } return showChatPreviewLine && formattedLastMessageText @@ -536,19 +549,15 @@ function isSearchStringMatchUserDetails(personalDetail: PersonalDetails, searchV /** * Get IOU report ID of report last action if the action is report action preview */ -function getIOUReportIDOfLastAction(report: OnyxEntry, visibleReportActionsData?: VisibleReportActionsDerivedValue, lastAction?: OnyxEntry): string | undefined { +function getIOUReportIDOfLastAction(report: OnyxEntry): string | undefined { if (!report?.reportID) { return; } - // Use lastAction if available (from useOnyx), otherwise fallback to getLastVisibleAction which uses isReportActionVisibleAsLastAction with proper filters - const reportNameValuePairs = allReportNameValuePairsOnyxConnect?.[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report.reportID}`]; - const isReportArchived = !!reportNameValuePairs?.private_isArchived; - const canUserPerformWrite = canUserPerformWriteAction(report, isReportArchived); - const action = lastAction ?? getLastVisibleAction(report.reportID, canUserPerformWrite, {}, undefined, visibleReportActionsData); - if (!isReportPreviewAction(action)) { + const lastAction = lastVisibleReportActions[report.reportID]; + if (!isReportPreviewAction(lastAction)) { return; } - return getReportOrDraftReport(getIOUReportIDFromReportActionPreview(action))?.reportID; + return getReportOrDraftReport(getIOUReportIDFromReportActionPreview(lastAction))?.reportID; } function hasHiddenDisplayNames(accountIDs: number[]) { @@ -559,21 +568,15 @@ function getLastActorDisplayNameFromLastVisibleActions( report: OnyxEntry, lastActorDetails: Partial | null, currentUserAccountIDParam: number, - personalDetails?: OnyxEntry, - visibleReportActionsData?: VisibleReportActionsDerivedValue, - lastAction?: OnyxEntry, + personalDetails: OnyxEntry, ): string { const reportID = report?.reportID; - // Use lastAction if available (from useOnyx), otherwise fallback to getLastVisibleAction which uses isReportActionVisibleAsLastAction with proper filters - const reportNameValuePairs = reportID ? allReportNameValuePairsOnyxConnect?.[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${reportID}`] : undefined; - const isReportArchived = !!reportNameValuePairs?.private_isArchived; - const canUserPerformWrite = canUserPerformWriteAction(report, isReportArchived); - const lastReportAction = lastAction ?? getLastVisibleAction(reportID, canUserPerformWrite, {}, undefined, visibleReportActionsData); + const lastReportAction = reportID ? lastVisibleReportActions[reportID] : undefined; if (lastReportAction) { // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const lastActorAccountID = getReportActionActorAccountID(lastReportAction, undefined, undefined) || report?.lastActorAccountID; - let actorDetails: Partial | null = lastActorAccountID ? ((personalDetails ?? allPersonalDetails)?.[lastActorAccountID] ?? null) : null; + let actorDetails: Partial | null = lastActorAccountID ? (personalDetails?.[lastActorAccountID] ?? null) : null; if (!actorDetails && lastReportAction.person?.at(0)?.text) { actorDetails = { @@ -603,8 +606,6 @@ function getLastMessageTextForReport({ isReportArchived = false, policyForMovingExpensesID, reportMetadata, - visibleReportActionsDataParam, - lastAction, currentUserAccountID, }: { translate: LocalizedTranslate; @@ -616,15 +617,11 @@ function getLastMessageTextForReport({ isReportArchived?: boolean; policyForMovingExpensesID?: string; reportMetadata?: OnyxEntry; - visibleReportActionsDataParam?: VisibleReportActionsDerivedValue; - lastAction?: OnyxEntry; - currentUserAccountID?: number; + currentUserAccountID: number; }): string { const reportID = report?.reportID; - // Use lastAction if available (from useOnyx), otherwise fallback to getLastVisibleAction which uses isReportActionVisibleAsLastAction with proper filters - const canUserPerformWrite = canUserPerformWriteAction(report, isReportArchived); - const lastReportAction = lastAction ?? getLastVisibleAction(reportID, canUserPerformWrite, {}, undefined, visibleReportActionsDataParam); - const lastVisibleMessage = getLastVisibleMessage(report?.reportID, undefined, {}, undefined, visibleReportActionsDataParam); + const lastReportAction = reportID ? lastVisibleReportActions[reportID] : undefined; + const lastVisibleMessage = getLastVisibleMessage(report?.reportID); // some types of actions are filtered out for lastReportAction, in some cases we need to check the actual last action const lastOriginalReportAction = reportID ? lastReportActions[reportID] : undefined; @@ -657,29 +654,21 @@ function getLastMessageTextForReport({ lastMessageTextFromReport = formatReportLastMessageText(Parser.htmlToText(properSchemaForMoneyRequestMessage)); } else if (isReportPreviewAction(lastReportAction)) { const iouReport = getReportOrDraftReport(getIOUReportIDFromReportActionPreview(lastReportAction)); - const iouReportID = iouReport?.reportID; - const reportCache = iouReportID ? visibleReportActionsDataParam?.[iouReportID] : undefined; - const visibleReportActionsForIOUReport = reportCache && Object.keys(reportCache).length > 0 ? visibleReportActionsDataParam : undefined; - const iouReportActions = iouReportID ? allSortedReportActions[iouReportID] : undefined; - const lastIOUMoneyReportAction = - iouReportID && iouReportActions - ? iouReportActions.find( - (reportAction): reportAction is ReportAction => - isReportActionVisible(reportAction, iouReportID, canUserPerformWriteAction(report, isReportArchived), visibleReportActionsForIOUReport) && - reportAction.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && - isMoneyRequestAction(reportAction), - ) - : undefined; - - if (reportUtilsIsPolicyExpenseChat(report) && iouReport?.reportID) { - const reportName = computeReportName(iouReport, undefined, undefined, undefined, undefined, undefined, undefined, currentUserAccountID); - lastMessageTextFromReport = formatReportLastMessageText(reportName); - } else if (!lastIOUMoneyReportAction && iouReport?.reportID) { + const lastIOUMoneyReportAction = iouReport?.reportID + ? allSortedReportActions[iouReport.reportID]?.find( + (reportAction, key): reportAction is ReportAction => + shouldReportActionBeVisible(reportAction, key, canUserPerformWriteAction(report, isReportArchived)) && + reportAction.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && + isMoneyRequestAction(reportAction), + ) + : undefined; + // For workspace chats, use the report title + if (reportUtilsIsPolicyExpenseChat(report) && !isEmptyObject(iouReport)) { const reportName = computeReportName(iouReport, undefined, undefined, undefined, undefined, undefined, undefined, currentUserAccountID); lastMessageTextFromReport = formatReportLastMessageText(reportName); } else { const reportPreviewMessage = getReportPreviewMessage( - iouReport?.reportID ? iouReport : null, + !isEmptyObject(iouReport) ? iouReport : null, lastIOUMoneyReportAction ?? lastReportAction, true, reportUtilsIsChatReport(report), @@ -877,8 +866,6 @@ function createOption( config?: PreviewConfig, reportAttributesDerived?: ReportAttributesDerivedValue['reports'], privateIsArchived?: string, - visibleReportActionsData: VisibleReportActionsDerivedValue = {}, - translate?: LocalizedTranslate, ): SearchOptionData { const {showChatPreviewLine = false, forcePolicyNamePreview = false, showPersonalDetails = false, selected, isSelected, isDisabled} = config ?? {}; @@ -955,27 +942,11 @@ function createOption( // If displaying chat preview line is needed, let's overwrite the default alternate text const lastActorDetails = personalDetails?.[report?.lastActorAccountID ?? String(CONST.DEFAULT_NUMBER_ID)] ?? {}; // eslint-disable-next-line @typescript-eslint/no-deprecated - const translateFn = translate ?? translateLocal; - result.lastMessageText = getLastMessageTextForReport({ - translate: translateFn, - report, - lastActorDetails, - isReportArchived: !!result.private_isArchived, - visibleReportActionsDataParam: visibleReportActionsData, - currentUserAccountID, - }); + result.lastMessageText = getLastMessageTextForReport({translate: translateLocal, report, lastActorDetails, isReportArchived: !!result.private_isArchived, currentUserAccountID}); result.alternateText = showPersonalDetails && personalDetail?.login ? personalDetail.login - : getAlternateText( - result, - {showChatPreviewLine, forcePolicyNamePreview}, - !!result.private_isArchived, - currentUserAccountID, - lastActorDetails, - visibleReportActionsData, - translateFn, - ); + : getAlternateText(result, {showChatPreviewLine, forcePolicyNamePreview}, !!result.private_isArchived, currentUserAccountID, lastActorDetails); const personalDetailsForCompute: PersonalDetailsList | undefined = personalDetails ?? undefined; const computedReportName = computeReportName( @@ -1038,7 +1009,6 @@ function getReportOption( personalDetails: OnyxEntry, reportAttributesDerived?: ReportAttributesDerivedValue['reports'], reportDrafts?: OnyxCollection, - visibleReportActionsData: VisibleReportActionsDerivedValue = {}, ): OptionData { const report = getReportOrDraftReport(participant.reportID, undefined, undefined, reportDrafts); const visibleParticipantAccountIDs = getParticipantsAccountIDsForDisplay(report, true); @@ -1054,7 +1024,6 @@ function getReportOption( }, reportAttributesDerived, privateIsArchived, - visibleReportActionsData, ); // Update text & alternateText because createOption returns workspace name only if report is owned by the user @@ -1098,7 +1067,6 @@ function getReportDisplayOption( personalDetails: OnyxEntry, privateIsArchived: string | undefined, reportAttributesDerived?: ReportAttributesDerivedValue['reports'], - visibleReportActionsData: VisibleReportActionsDerivedValue = {}, ): OptionData { const visibleParticipantAccountIDs = getParticipantsAccountIDsForDisplay(report, true); @@ -1113,7 +1081,6 @@ function getReportDisplayOption( }, reportAttributesDerived, privateIsArchived, - visibleReportActionsData, ); // Update text & alternateText because createOption returns workspace name only if report is owned by the user @@ -1147,7 +1114,6 @@ function getPolicyExpenseReportOption( currentUserAccountID: number, personalDetails: OnyxEntry, reportAttributesDerived?: ReportAttributesDerivedValue['reports'], - visibleReportActionsData: VisibleReportActionsDerivedValue = {}, ): SearchOptionData { const expenseReport = reportUtilsIsPolicyExpenseChat(participant) ? getReportOrDraftReport(participant.reportID) : null; @@ -1165,8 +1131,6 @@ function getPolicyExpenseReportOption( forcePolicyNamePreview: false, }, reportAttributesDerived, - undefined, - visibleReportActionsData, ); // Update text & alternateText because createOption returns workspace name only if report is owned by the user @@ -1277,7 +1241,6 @@ function processReport( personalDetails: OnyxEntry, currentUserAccountID: number, reportAttributesDerived?: ReportAttributesDerivedValue['reports'], - visibleReportActionsData: VisibleReportActionsDerivedValue = {}, ): { reportMapEntry?: [number, Report]; // The entry to add to reportMapForAccountIDs if applicable reportOption: SearchOption | null; // The report option to add to allReportOptions if applicable @@ -1301,7 +1264,7 @@ function processReport( reportMapEntry, reportOption: { item: report, - ...createOption(accountIDs, personalDetails, report, currentUserAccountID, undefined, reportAttributesDerived, undefined, visibleReportActionsData), + ...createOption(accountIDs, personalDetails, report, currentUserAccountID, undefined, reportAttributesDerived), }, }; } @@ -1311,7 +1274,6 @@ function createOptionList( currentUserAccountID: number, reports?: OnyxCollection, reportAttributesDerived?: ReportAttributesDerivedValue['reports'], - visibleReportActionsData: VisibleReportActionsDerivedValue = {}, ) { const span = Sentry.startInactiveSpan({name: 'createOptionList'}); @@ -1320,7 +1282,7 @@ function createOptionList( if (reports) { for (const report of Object.values(reports)) { - const {reportMapEntry, reportOption} = processReport(report, personalDetails, currentUserAccountID, reportAttributesDerived, visibleReportActionsData); + const {reportMapEntry, reportOption} = processReport(report, personalDetails, currentUserAccountID, reportAttributesDerived); if (reportMapEntry) { const [accountID, reportValue] = reportMapEntry; @@ -1344,8 +1306,6 @@ function createOptionList( showPersonalDetails: true, }, reportAttributesDerived, - undefined, - visibleReportActionsData, ), })); @@ -1385,7 +1345,6 @@ function createFilteredOptionList( searchTerm?: string; betas?: OnyxEntry; } = {}, - visibleReportActionsData: VisibleReportActionsDerivedValue = {}, ) { const {maxRecentReports = 500, includeP2P = true, searchTerm = ''} = options; const reportMapForAccountIDs: Record = {}; @@ -1435,7 +1394,7 @@ function createFilteredOptionList( // Step 5: Process the limited set of reports (performance optimization) const reportOptions: Array> = []; for (const report of limitedReports) { - const {reportMapEntry, reportOption} = processReport(report, personalDetails, currentUserAccountID, reportAttributesDerived, visibleReportActionsData); + const {reportMapEntry, reportOption} = processReport(report, personalDetails, currentUserAccountID, reportAttributesDerived); if (reportMapEntry) { const [accountID, reportValue] = reportMapEntry; @@ -1465,16 +1424,7 @@ function createFilteredOptionList( return { item: personalDetail, - ...createOption( - [accountID], - personalDetails, - reportMapForAccountIDs[accountID], - currentUserAccountID, - {showPersonalDetails: true}, - reportAttributesDerived, - undefined, - visibleReportActionsData, - ), + ...createOption([accountID], personalDetails, reportMapForAccountIDs[accountID], currentUserAccountID, {showPersonalDetails: true}, reportAttributesDerived), }; }) : []; @@ -1491,13 +1441,12 @@ function createOptionFromReport( currentUserAccountID: number, reportAttributesDerived?: ReportAttributesDerivedValue['reports'], config?: PreviewConfig, - visibleReportActionsData: VisibleReportActionsDerivedValue = {}, ) { const accountIDs = getParticipantsAccountIDsForDisplay(report); return { item: report, - ...createOption(accountIDs, personalDetails, report, currentUserAccountID, config, reportAttributesDerived, undefined, visibleReportActionsData), + ...createOption(accountIDs, personalDetails, report, currentUserAccountID, config, reportAttributesDerived), }; } @@ -1776,6 +1725,7 @@ function canCreateOptimisticPersonalDetailOption({ */ function getUserToInviteOption({ searchValue, + searchInputValue, loginsToExclude = {}, selectedOptions = [], showChatPreviewLine = false, @@ -1784,8 +1734,7 @@ function getUserToInviteOption({ loginList = {}, currentUserEmail, currentUserAccountID, - visibleReportActionsData = {}, -}: GetUserToInviteConfig & {visibleReportActionsData?: VisibleReportActionsDerivedValue}): SearchOptionData | null { +}: GetUserToInviteConfig): SearchOptionData | null { if (!searchValue) { return null; } @@ -1796,6 +1745,9 @@ function getUserToInviteOption({ const isValidEmail = Str.isValidEmail(searchValue) && !Str.isDomainEmail(searchValue) && !Str.endsWith(searchValue, CONST.SMS.DOMAIN); const isValidPhoneNumber = parsedPhoneNumber.possible && Str.isValidE164Phone(getPhoneNumberWithoutSpecialChars(parsedPhoneNumber.number?.input ?? '')); const isInOptionToExclude = loginsToExclude[addSMSDomainIfPhoneNumber(searchValue).toLowerCase()]; + const trimmedSearchInputValue = searchInputValue?.trim(); + const shouldUseSearchInputValue = shouldAcceptName && !!trimmedSearchInputValue && !isValidEmail && !isValidPhoneNumber; + const displayValue = shouldUseSearchInputValue ? trimmedSearchInputValue : searchValue; // Angle brackets are not valid characters for user names const hasInvalidCharacters = shouldAcceptName && (searchValue.includes('<') || searchValue.includes('>')); @@ -1813,24 +1765,24 @@ function getUserToInviteOption({ login: searchValue, }, }; - const userToInvite = createOption( - [optimisticAccountID], - personalDetailsExtended, - null, - currentUserAccountID, - { - showChatPreviewLine, - }, - undefined, - undefined, - visibleReportActionsData, - ); + const userToInvite = createOption([optimisticAccountID], personalDetailsExtended, null, currentUserAccountID, { + showChatPreviewLine, + }); userToInvite.isOptimisticAccount = true; userToInvite.login = searchValue; - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - userToInvite.text = userToInvite.text || searchValue; - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - userToInvite.alternateText = userToInvite.alternateText || searchValue; + + if (shouldUseSearchInputValue) { + userToInvite.text = displayValue; + userToInvite.displayName = displayValue; + userToInvite.alternateText = displayValue; + } else { + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + userToInvite.text = userToInvite.text || displayValue; + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + userToInvite.displayName = userToInvite.displayName || displayValue; + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + userToInvite.alternateText = userToInvite.alternateText || displayValue; + } // If user doesn't exist, use a fallback avatar userToInvite.icons = [ @@ -2094,7 +2046,6 @@ function prepareReportOptionsForDisplay( policiesCollection: OnyxCollection, currentUserAccountID: number, config: GetValidReportsConfig, - visibleReportActionsData: VisibleReportActionsDerivedValue = {}, ): Array> { const { showChatPreviewLine = false, @@ -2125,7 +2076,7 @@ function prepareReportOptionsForDisplay( * By default, generated options does not have the chat preview line enabled. * If showChatPreviewLine or forcePolicyNamePreview are true, let's generate and overwrite the alternate text. */ - const alternateText = getAlternateText(option, {showChatPreviewLine, forcePolicyNamePreview}, !!option.private_isArchived, currentUserAccountID, null, visibleReportActionsData); + const alternateText = getAlternateText(option, {showChatPreviewLine, forcePolicyNamePreview}, !!option.private_isArchived, currentUserAccountID); const isSelected = isReportSelected(option, selectedOptions); let isOptionUnread = option.isUnread; @@ -2255,6 +2206,7 @@ function getValidOptions( excludeHiddenThreads = false, canShowManagerMcTest = false, searchString, + searchInputValue, maxElements, includeUserToInvite = false, maxRecentReportElements = undefined, @@ -2263,7 +2215,6 @@ function getValidOptions( ...config }: GetOptionsConfig = {}, countryCode: number = CONST.DEFAULT_COUNTRY_CODE, - visibleReportActionsData: VisibleReportActionsDerivedValue = {}, ): Options { const restrictedLogins = getRestrictedLogins(config, options, canShowManagerMcTest, nvpDismissedProductTraining); @@ -2337,31 +2288,7 @@ function getValidOptions( [selfDMChats, workspaceChats, recentReportOptions] = optionsOrderAndGroupBy([isSelfDMChat, isWorkspaceChat], options.reports, recentReportComparator, maxElements, filteringFunction); if (selfDMChats.length > 0) { - selfDMChat = prepareReportOptionsForDisplay( - selfDMChats, - policiesCollection, - currentUserAccountID, - { - ...getValidReportsConfig, - selectedOptions, - shouldBoldTitleByDefault, - shouldSeparateSelfDMChat, - shouldSeparateWorkspaceChat, - shouldShowGBR, - personalDetails, - }, - visibleReportActionsData, - ).at(0); - } - - if (maxRecentReportElements) { - recentReportOptions = recentReportOptions.splice(0, maxRecentReportElements); - } - recentReportOptions = prepareReportOptionsForDisplay( - recentReportOptions, - policiesCollection, - currentUserAccountID, - { + selfDMChat = prepareReportOptionsForDisplay(selfDMChats, policiesCollection, currentUserAccountID, { ...getValidReportsConfig, selectedOptions, shouldBoldTitleByDefault, @@ -2369,25 +2296,31 @@ function getValidOptions( shouldSeparateWorkspaceChat, shouldShowGBR, personalDetails, - }, - visibleReportActionsData, - ); + }).at(0); + } - workspaceChats = prepareReportOptionsForDisplay( - workspaceChats, - policiesCollection, - currentUserAccountID, - { - ...getValidReportsConfig, - selectedOptions, - shouldBoldTitleByDefault, - shouldSeparateSelfDMChat, - shouldSeparateWorkspaceChat, - shouldShowGBR, - personalDetails, - }, - visibleReportActionsData, - ); + if (maxRecentReportElements) { + recentReportOptions = recentReportOptions.splice(0, maxRecentReportElements); + } + recentReportOptions = prepareReportOptionsForDisplay(recentReportOptions, policiesCollection, currentUserAccountID, { + ...getValidReportsConfig, + selectedOptions, + shouldBoldTitleByDefault, + shouldSeparateSelfDMChat, + shouldSeparateWorkspaceChat, + shouldShowGBR, + personalDetails, + }); + + workspaceChats = prepareReportOptionsForDisplay(workspaceChats, policiesCollection, currentUserAccountID, { + ...getValidReportsConfig, + selectedOptions, + shouldBoldTitleByDefault, + shouldSeparateSelfDMChat, + shouldSeparateWorkspaceChat, + shouldShowGBR, + personalDetails, + }); } else if (recentAttendees && recentAttendees?.length > 0) { recentAttendees.filter((attendee) => { const login = attendee.login ?? attendee.displayName; @@ -2474,6 +2407,7 @@ function getValidOptions( { excludeLogins: loginsToExclude, shouldAcceptName, + searchInputValue, }, ); } @@ -2504,7 +2438,6 @@ type SearchOptionsConfig = { shouldShowGBR?: boolean; shouldUnreadBeBold?: boolean; loginList: OnyxEntry; - visibleReportActionsData?: VisibleReportActionsDerivedValue; currentUserAccountID: number; currentUserEmail: string; personalDetails?: OnyxEntry; @@ -2529,7 +2462,6 @@ function getSearchOptions({ shouldShowGBR = false, shouldUnreadBeBold = false, loginList, - visibleReportActionsData = {}, currentUserAccountID, currentUserEmail, }: SearchOptionsConfig): Options { @@ -2567,7 +2499,6 @@ function getSearchOptions({ shouldUnreadBeBold, }, countryCode, - visibleReportActionsData, ); Timing.end(CONST.TIMING.LOAD_SEARCH_OPTIONS); @@ -2621,11 +2552,24 @@ function getFilteredRecentAttendees( }); } - const filteredRecentAttendees = recentAttendees + // Deduplicate recentAttendees: use email for regular users, displayName for name-only attendees + const seenAttendees = new Set(); + const deduplicatedRecentAttendees = recentAttendees.filter((attendee) => { + const key = attendee.email || attendee.displayName || ''; + if (seenAttendees.has(key)) { + return false; + } + seenAttendees.add(key); + return true; + }); + + const filteredRecentAttendees = deduplicatedRecentAttendees .filter((attendee) => !attendees.find(({email, displayName}) => (attendee.email ? email === attendee.email : displayName === attendee.displayName))) .map((attendee) => ({ ...attendee, - login: attendee.email ? attendee.email : attendee.displayName, + // Use || instead of ?? to handle empty string email for name-only attendees + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + login: attendee.email || attendee.displayName, ...getPersonalDetailByEmail(attendee.email), })) .map((attendee) => getParticipantsOption(attendee, personalDetails)); @@ -2673,7 +2617,6 @@ function getMemberInviteOptions( excludeLogins: Record = {}, includeSelectedOptions = false, countryCode: number = CONST.DEFAULT_COUNTRY_CODE, - visibleReportActionsData: VisibleReportActionsDerivedValue = {}, ): Options { return getValidOptions( {personalDetails, reports: []}, @@ -2693,7 +2636,6 @@ function getMemberInviteOptions( maxElements: undefined, }, countryCode, - visibleReportActionsData, ); } @@ -3004,6 +2946,7 @@ function filterOptions( config?: FilterUserToInviteConfig, ): Options { const trimmedSearchInput = searchInputValue.trim(); + const searchInputValueForInvite = config?.searchInputValue ?? trimmedSearchInput; // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const parsedPhoneNumber = parsePhoneNumber(appendCountryCode(Str.removeSMSDomain(trimmedSearchInput), countryCode || CONST.DEFAULT_COUNTRY_CODE)); @@ -3024,7 +2967,10 @@ function filterOptions( currentUserEmail, currentUserAccountID, countryCode, - config, + { + ...config, + searchInputValue: searchInputValueForInvite, + }, ); const workspaceChats = filterWorkspaceChats(options.workspaceChats ?? [], searchTerms); diff --git a/src/libs/OptionsListUtils/types.ts b/src/libs/OptionsListUtils/types.ts index d1e2d633870a..2377295db91a 100644 --- a/src/libs/OptionsListUtils/types.ts +++ b/src/libs/OptionsListUtils/types.ts @@ -193,6 +193,7 @@ type GetOptionsConfig = { excludeHiddenThreads?: boolean; canShowManagerMcTest?: boolean; searchString?: string; + searchInputValue?: string; maxElements?: number; maxRecentReportElements?: number; includeUserToInvite?: boolean; @@ -201,6 +202,7 @@ type GetOptionsConfig = { type GetUserToInviteConfig = { searchValue: string | undefined; + searchInputValue?: string; loginsToExclude?: Record; reportActions?: ReportActions; firstName?: string; @@ -251,7 +253,7 @@ type PreviewConfig = { isSelected?: boolean; }; -type FilterUserToInviteConfig = Pick & { +type FilterUserToInviteConfig = Pick & { canInviteUser?: boolean; excludeLogins?: Record; }; diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts index 81841894c88d..bc74f35b1404 100644 --- a/src/libs/PolicyUtils.ts +++ b/src/libs/PolicyUtils.ts @@ -284,17 +284,17 @@ function hasEligibleActiveAdminFromWorkspaces(policies: OnyxCollection | return false; } -function getCustomUnitsForDuplication(policy: Policy, isCustomUnitsOptionSelected: boolean, isPerDiemOptionSelected: boolean): Record | undefined { +function getCustomUnitsForDuplication(policy: Policy, isDistanceRatesOptionSelected: boolean, isPerDiemOptionSelected: boolean): Record | undefined { const customUnits = policy?.customUnits; - if ((!isCustomUnitsOptionSelected && !isPerDiemOptionSelected) || !customUnits || Object.keys(customUnits).length === 0) { + if ((!isDistanceRatesOptionSelected && !isPerDiemOptionSelected) || !customUnits || Object.keys(customUnits).length === 0) { return undefined; } - if (isCustomUnitsOptionSelected && isPerDiemOptionSelected) { + if (isDistanceRatesOptionSelected && isPerDiemOptionSelected) { return customUnits; } - if (isCustomUnitsOptionSelected) { + if (isDistanceRatesOptionSelected) { const distanceCustomUnit = Object.values(customUnits).find((customUnit) => customUnit.name === CONST.CUSTOM_UNITS.NAME_DISTANCE); if (!distanceCustomUnit) { return undefined; diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts index fcd2631b1c87..33c10db00101 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -15,17 +15,7 @@ import IntlStore from '@src/languages/IntlStore'; import type {TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import type { - Card, - OnyxInputOrEntry, - OriginalMessageIOU, - PersonalDetails, - Policy, - PrivatePersonalDetails, - ReportMetadata, - ReportNameValuePairs, - VisibleReportActionsDerivedValue, -} from '@src/types/onyx'; +import type {Card, OnyxInputOrEntry, OriginalMessageIOU, PersonalDetails, Policy, PrivatePersonalDetails, ReportMetadata, ReportNameValuePairs} from '@src/types/onyx'; import type { JoinWorkspaceResolution, OriginalMessageChangeLog, @@ -234,7 +224,7 @@ function getHtmlWithAttachmentID(html: string, reportActionID: string | undefine } function getReportActionMessage(reportAction: PartialReportAction) { - return Array.isArray(reportAction?.message) ? reportAction?.message.at(0) : reportAction?.message; + return Array.isArray(reportAction?.message) ? reportAction.message.at(0) : reportAction?.message; } function isDeletedParentAction(reportAction: OnyxInputOrEntry): boolean { @@ -419,7 +409,7 @@ function getOriginalMessage(reportAction: OnyxInputO return reportAction?.message ?? reportAction?.originalMessage; } // eslint-disable-next-line @typescript-eslint/no-deprecated - return reportAction?.originalMessage; + return reportAction.originalMessage; } function getMarkedReimbursedMessage(reportAction: OnyxInputOrEntry): string { @@ -1178,75 +1168,6 @@ function shouldReportActionBeVisibleAsLastAction(reportAction: OnyxInputOrEntry< ); } -/** - * Checks if a report action is visible using the pre-computed derived value when available, - * falling back to runtime calculation if not. - */ -function isReportActionVisible( - reportAction: OnyxEntry, - reportID: string, - canUserPerformWriteAction?: boolean, - visibleReportActions?: VisibleReportActionsDerivedValue, -): boolean { - if (!reportAction?.reportActionID) { - return false; - } - - if (reportAction.pendingAction) { - return shouldReportActionBeVisible(reportAction, reportAction.reportActionID, canUserPerformWriteAction); - } - - if (visibleReportActions) { - const reportCache = visibleReportActions[reportID]; - if (!reportCache) { - return shouldReportActionBeVisible(reportAction, reportAction.reportActionID, canUserPerformWriteAction); - } - const staticVisibility = reportCache[reportAction.reportActionID]; - // If action is not in derived value cache, fall back to runtime calculation - if (staticVisibility === undefined) { - return shouldReportActionBeVisible(reportAction, reportAction.reportActionID, canUserPerformWriteAction); - } - if (!staticVisibility) { - return false; - } - if (!canUserPerformWriteAction && isActionableWhisperRequiringWritePermission(reportAction)) { - return false; - } - return true; - } - return shouldReportActionBeVisible(reportAction, reportAction.reportActionID, canUserPerformWriteAction); -} - -/** - * Checks if a report action is visible as last action using the pre-computed derived value when available, - * falling back to runtime calculation if not. - */ -function isReportActionVisibleAsLastAction( - reportAction: OnyxInputOrEntry, - canUserPerformWriteAction?: boolean, - visibleReportActions?: VisibleReportActionsDerivedValue, - reportID?: string, -): boolean { - if (!reportAction) { - return false; - } - - if (Object.keys(reportAction.errors ?? {}).length > 0) { - return false; - } - - const actionReportID = reportAction.reportID ?? reportID; - if (!actionReportID) { - return false; - } - - return ( - isReportActionVisible(reportAction, actionReportID, canUserPerformWriteAction, visibleReportActions) && - (!(isWhisperAction(reportAction) && !isReportPreviewAction(reportAction) && !isMoneyRequestAction(reportAction)) || isActionableMentionWhisper(reportAction)) && - !(isDeletedAction(reportAction) && !isDeletedParentAction(reportAction) && !isPendingHide(reportAction)) - ); -} - /** * For policy change logs, report URLs are generated in the server, * which includes a baseURL placeholder that's replaced in the client. @@ -1278,7 +1199,6 @@ function getLastVisibleAction( canUserPerformWriteAction?: boolean, actionsToMerge: Record | null> = {}, reportActionsParam: OnyxCollection = allReportActions, - visibleReportActionsData?: VisibleReportActionsDerivedValue, ): OnyxEntry { let reportActions: Array = []; if (!isEmpty(actionsToMerge)) { @@ -1286,13 +1206,9 @@ function getLastVisibleAction( ReportAction | null | undefined >; } else { - reportActions = Object.values(reportActionsParam?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`] ?? {}); + reportActions = Object.values(allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`] ?? {}); } - // Pass reportID as fallback for actions that don't have it set (e.g., optimistic actions) - // This is cleaner than mutating the actions array - const visibleReportActions = reportActions.filter((action): action is ReportAction => - isReportActionVisibleAsLastAction(action, canUserPerformWriteAction, visibleReportActionsData, reportID), - ); + const visibleReportActions = reportActions.filter((action): action is ReportAction => shouldReportActionBeVisibleAsLastAction(action, canUserPerformWriteAction)); const sortedReportActions = getSortedReportActions(visibleReportActions, true); if (sortedReportActions.length === 0) { return undefined; @@ -1320,9 +1236,8 @@ function getLastVisibleMessage( canUserPerformWriteAction?: boolean, actionsToMerge: Record | null> = {}, reportAction: OnyxInputOrEntry | undefined = undefined, - visibleReportActionsData?: VisibleReportActionsDerivedValue, ): LastVisibleMessage { - const lastVisibleAction = reportAction ?? getLastVisibleAction(reportID, canUserPerformWriteAction, actionsToMerge, undefined, visibleReportActionsData); + const lastVisibleAction = reportAction ?? getLastVisibleAction(reportID, canUserPerformWriteAction, actionsToMerge); const message = getReportActionMessage(lastVisibleAction); if (message && isReportMessageAttachment(message)) { @@ -1424,8 +1339,6 @@ function getSortedReportActionsForDisplay( reportActions: OnyxEntry | ReportAction[], canUserPerformWriteAction?: boolean, shouldIncludeInvisibleActions = false, - visibleReportActionsData?: VisibleReportActionsDerivedValue, - reportID?: string, ): ReportAction[] { let filteredReportActions: ReportAction[] = []; if (!reportActions) { @@ -1436,16 +1349,7 @@ function getSortedReportActionsForDisplay( filteredReportActions = Object.values(reportActions).filter(Boolean); } else { filteredReportActions = Object.entries(reportActions) - .filter(([collectionKey, reportAction]) => { - if (isReportActionDeprecated(reportAction, collectionKey)) { - return false; - } - const actionReportID = reportAction?.reportID ?? reportID; - if (!actionReportID) { - return false; - } - return isReportActionVisible(reportAction, actionReportID, canUserPerformWriteAction, visibleReportActionsData); - }) + .filter(([key, reportAction]) => shouldReportActionBeVisible(reportAction, key, canUserPerformWriteAction)) .map(([, reportAction]) => reportAction); } @@ -1744,16 +1648,9 @@ function getOneTransactionThreadReportID(...args: Parameters - isReportActionVisibleAsLastAction(action, canUserPerformWriteAction, visibleReportActionsData, reportID), - ); + const visibleReportActions = Object.values(reportActions ?? {}).filter((action) => shouldReportActionBeVisibleAsLastAction(action, canUserPerformWriteAction)); // Exclude the task system message and the created message const visibleReportActionsWithoutTaskSystemMessage = visibleReportActions.filter((action) => !isTaskAction(action) && !isCreatedAction(action)); @@ -1917,7 +1814,7 @@ function getMessageOfOldDotLegacyAction(legacyAction: PartialReportAction) { if (!Array.isArray(legacyAction?.message)) { return getReportActionText(legacyAction); } - if (legacyAction?.message.length !== 0) { + if (legacyAction.message.length !== 0) { // Sometime html can be an empty string // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing return legacyAction?.message?.map((element) => getTextFromHtml(element?.html || element?.text)).join('') ?? ''; @@ -2407,22 +2304,6 @@ function isActionableCardFraudAlert(reportAction: OnyxInputOrEntry return reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.ACTIONABLE_CARD_FRAUD_ALERT; } -/** - * Checks if a report action is an actionable whisper that requires write permission to be visible. - */ -function isActionableWhisperRequiringWritePermission(reportAction: OnyxEntry): boolean { - if (!reportAction) { - return false; - } - - return ( - isActionableReportMentionWhisper(reportAction) || - isActionableJoinRequestPendingReportAction(reportAction) || - isActionableMentionWhisper(reportAction) || - isActionableCardFraudAlert(reportAction) - ); -} - function getExportIntegrationLastMessageText(translate: LocalizedTranslate, reportAction: OnyxEntry): string { const fragments = getExportIntegrationActionFragments(translate, reportAction); return fragments.reduce((acc, fragment) => `${acc} ${fragment.text}`, ''); @@ -3976,7 +3857,6 @@ export { isActionableWhisper, isActionableJoinRequest, isActionableJoinRequestPending, - isActionableJoinRequestPendingReportAction, isActionableMentionWhisper, isActionableMentionInviteToSubmitExpenseConfirmWhisper, isActionableReportMentionWhisper, @@ -4031,7 +3911,6 @@ export { isTrackExpenseAction, isTransactionThread, isTripPreview, - isTravelUpdate, isHoldAction, isWhisperAction, isSubmittedAction, @@ -4052,18 +3931,14 @@ export { isTagModificationAction, isIOUActionMatchingTransactionList, isResolvedActionableWhisper, - isVisiblePreviewOrMoneyRequest, isReimbursementDirectionInformationRequiredAction, shouldHideNewMarker, shouldReportActionBeVisible, shouldReportActionBeVisibleAsLastAction, - isReportActionVisible, - isReportActionVisibleAsLastAction, wasActionTakenByCurrentUser, isInviteOrRemovedAction, isActionableAddPaymentCard, isActionableCardFraudAlert, - isActionableWhisperRequiringWritePermission, getExportIntegrationActionFragments, getExportIntegrationLastMessageText, getExportIntegrationMessageHTML, diff --git a/src/libs/ReportSecondaryActionUtils.ts b/src/libs/ReportSecondaryActionUtils.ts index 491235787506..c9759925f28a 100644 --- a/src/libs/ReportSecondaryActionUtils.ts +++ b/src/libs/ReportSecondaryActionUtils.ts @@ -103,13 +103,18 @@ function isSplitAction( } const reportTransaction = reportTransactions.at(0); + const {amount} = getTransactionDetails(reportTransaction) ?? {}; + + if (isPending(reportTransaction) || !!reportTransaction?.errors) { + return false; + } const isScanning = hasReceiptTransactionUtils(reportTransaction) && isReceiptBeingScanned(reportTransaction); - if (isPending(reportTransaction) || isScanning || !!reportTransaction?.errors) { + + if (isScanning && !amount) { return false; } - const {amount} = getTransactionDetails(reportTransaction) ?? {}; if (!amount) { return false; } diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 7246c21c56ca..0127b0521923 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -60,7 +60,6 @@ import type { Transaction, TransactionViolation, TransactionViolations, - VisibleReportActionsDerivedValue, } from '@src/types/onyx'; import type {ReportTransactionsAndViolations} from '@src/types/onyx/DerivedValues'; import type {Attendee, Participant} from '@src/types/onyx/IOU'; @@ -268,7 +267,6 @@ import { isRenamedAction, isReopenedAction, isReportActionAttachment, - isReportActionVisible, isReportPreviewAction, isRetractedAction, isReversedTransaction, @@ -283,6 +281,7 @@ import { isTripPreview, isUnapprovedAction, isWhisperAction, + shouldReportActionBeVisible, wasActionTakenByCurrentUser, } from './ReportActionsUtils'; import type {LastVisibleMessage} from './ReportActionsUtils'; @@ -1898,7 +1897,10 @@ function getDefaultNotificationPreferenceForReport(report: OnyxEntry): V */ function getReportNotificationPreference(report: OnyxEntry): ValueOf { const participant = currentUserAccountID ? report?.participants?.[currentUserAccountID] : undefined; - return participant?.notificationPreference ?? CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN; + + // Empty notification preference should return `hidden` + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + return participant?.notificationPreference || CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN; } /** @@ -2667,6 +2669,9 @@ function isOneTransactionReport(report: OnyxEntry): boolean { /** * Checks if a report has only one transaction associated with it + * NOTE: This function should not be exported because it accesses module-level Onyx data (allReportActions, allReports) + * which can become stale. Each file should implement its own version using local Onyx collections. + * See tests/actions/EnforceActionExportRestrictions.ts for more details. * @deprecated - Use isOneTransactionReport instead */ function isOneTransactionReportDeprecated(report: OnyxEntry): boolean { @@ -7041,7 +7046,7 @@ function getMovedTransactionMessage(translate: LocalizedTranslate, action: Repor // This will be fixed as follow up https://github.com/Expensify/App/pull/75357 // eslint-disable-next-line @typescript-eslint/no-deprecated - const reportName = Parser.htmlToText(getReportName(report) ?? report?.reportName ?? ''); + const reportName = getReportName(report) ?? report?.reportName ?? ''; const reportUrl = getReportURLForCurrentContext(report?.reportID); if (typeof fromReportID === 'undefined') { return translate('iou.movedTransactionTo', { @@ -7063,7 +7068,7 @@ function getUnreportedTransactionMessage(translate: LocalizedTranslate, action: // This will be fixed as follow up https://github.com/Expensify/App/pull/75357 // eslint-disable-next-line @typescript-eslint/no-deprecated - const reportName = Parser.htmlToText(getReportName(fromReport) ?? fromReport?.reportName ?? ''); + const reportName = getReportName(fromReport) ?? fromReport?.reportName ?? ''; let reportUrl = getReportURLForCurrentContext(fromReportID); @@ -9164,23 +9169,14 @@ function isReportNotFound(report: OnyxEntry): boolean { /** * Check if the report is the parent report of the currently viewed report or at least one child report has report action */ -function shouldHideReport( - report: OnyxEntry, - currentReportId: string | undefined, - isReportArchived: boolean | undefined, - visibleReportActionsData?: VisibleReportActionsDerivedValue, -): boolean { +function shouldHideReport(report: OnyxEntry, currentReportId: string | undefined, isReportArchived: boolean | undefined): boolean { const currentReport = getReportOrDraftReport(currentReportId); const parentReport = getParentReport(!isEmptyObject(currentReport) ? currentReport : undefined); const reportActions = allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report?.reportID}`] ?? {}; - const reportID = report?.reportID; - const isChildReportHasComment = - !!reportID && - Object.values(reportActions ?? {})?.some( - (reportAction) => - (reportAction?.childVisibleActionCount ?? 0) > 0 && - isReportActionVisible(reportAction, reportID, canUserPerformWriteAction(report, isReportArchived), visibleReportActionsData), - ); + const isChildReportHasComment = Object.values(reportActions ?? {})?.some( + (reportAction) => + (reportAction?.childVisibleActionCount ?? 0) > 0 && shouldReportActionBeVisible(reportAction, reportAction.reportActionID, canUserPerformWriteAction(report, isReportArchived)), + ); return parentReport?.reportID !== report?.reportID && !isChildReportHasComment; } @@ -10208,13 +10204,7 @@ function isMoneyRequestReportPendingDeletion(reportOrID: OnyxEntry | str return parentReportAction?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; } -function navigateToLinkedReportAction( - ancestor: Ancestor, - isInNarrowPaneModal: boolean, - canUserPerformWrite: boolean | undefined, - isOffline: boolean, - visibleReportActionsData?: VisibleReportActionsDerivedValue, -) { +function navigateToLinkedReportAction(ancestor: Ancestor, isInNarrowPaneModal: boolean, canUserPerformWrite: boolean | undefined, isOffline: boolean) { if (isInNarrowPaneModal) { Navigation.navigate( ROUTES.SEARCH_REPORT.getRoute({ @@ -10229,8 +10219,7 @@ function navigateToLinkedReportAction( // Pop the thread report screen before navigating to the chat report. Navigation.goBack(ROUTES.REPORT_WITH_ID.getRoute(ancestor.report.reportID)); - const reportID = ancestor.report.reportID; - const isVisibleAction = !!reportID && isReportActionVisible(ancestor.reportAction, reportID, canUserPerformWrite, visibleReportActionsData); + const isVisibleAction = shouldReportActionBeVisible(ancestor.reportAction, ancestor.reportAction.reportActionID, canUserPerformWrite); if (isVisibleAction && !isOffline) { // Pop the chat report screen before navigating to the linked report action. diff --git a/src/libs/SearchParser/autocompleteParser.js b/src/libs/SearchParser/autocompleteParser.js index 4f8f6d7b4fb9..86e0ed536c01 100644 --- a/src/libs/SearchParser/autocompleteParser.js +++ b/src/libs/SearchParser/autocompleteParser.js @@ -559,10 +559,10 @@ function peg$parse(input, options) { var peg$f78 = function() { return "groupCategory"; }; var peg$f79 = function() { return "groupTag"; }; var peg$f80 = function() { return "groupMerchant"; }; - var peg$f81 = function() { return "groupMonth"; }; - var peg$f82 = function() { return "groupWeek"; }; - var peg$f83 = function() { return "groupYear"; }; - var peg$f84 = function() { return "groupQuarter"; }; + var peg$f81 = function() { return "groupmonth"; }; + var peg$f82 = function() { return "groupweek"; }; + var peg$f83 = function() { return "groupyear"; }; + var peg$f84 = function() { return "groupquarter"; }; var peg$f85 = function() { return "eq"; }; var peg$f86 = function() { return "neq"; }; var peg$f87 = function() { return "gte"; }; diff --git a/src/libs/SearchParser/baseRules.peggy b/src/libs/SearchParser/baseRules.peggy index 884aab360f4f..d5fc16bd72fd 100644 --- a/src/libs/SearchParser/baseRules.peggy +++ b/src/libs/SearchParser/baseRules.peggy @@ -146,10 +146,10 @@ groupWithdrawalId = "group-withdrawal-id"i &wordBoundary { return "gr groupCategory = "group-category"i &wordBoundary { return "groupCategory"; } groupTag = "group-tag"i &wordBoundary { return "groupTag"; } groupMerchant = "group-merchant"i &wordBoundary { return "groupMerchant"; } -groupMonth = "group-month"i &wordBoundary { return "groupMonth"; } -groupWeek = "group-week"i &wordBoundary { return "groupWeek"; } -groupYear = "group-year"i &wordBoundary { return "groupYear"; } -groupQuarter = "group-quarter"i &wordBoundary { return "groupQuarter"; } +groupMonth = "group-month"i &wordBoundary { return "groupmonth"; } +groupWeek = "group-week"i &wordBoundary { return "groupweek"; } +groupYear = "group-year"i &wordBoundary { return "groupyear"; } +groupQuarter = "group-quarter"i &wordBoundary { return "groupquarter"; } operator "operator" diff --git a/src/libs/SearchParser/searchParser.js b/src/libs/SearchParser/searchParser.js index 69589aabb68d..5d2d631c0446 100644 --- a/src/libs/SearchParser/searchParser.js +++ b/src/libs/SearchParser/searchParser.js @@ -586,10 +586,10 @@ function peg$parse(input, options) { var peg$f79 = function() { return "groupCategory"; }; var peg$f80 = function() { return "groupTag"; }; var peg$f81 = function() { return "groupMerchant"; }; - var peg$f82 = function() { return "groupMonth"; }; - var peg$f83 = function() { return "groupWeek"; }; - var peg$f84 = function() { return "groupYear"; }; - var peg$f85 = function() { return "groupQuarter"; }; + var peg$f82 = function() { return "groupmonth"; }; + var peg$f83 = function() { return "groupweek"; }; + var peg$f84 = function() { return "groupyear"; }; + var peg$f85 = function() { return "groupquarter"; }; var peg$f86 = function() { return "eq"; }; var peg$f87 = function() { return "neq"; }; var peg$f88 = function() { return "gte"; }; @@ -4493,11 +4493,11 @@ function peg$parse(input, options) { const GROUP_BY_DEFAULT_SORT = { - from: "from", - card: "card", - "withdrawal-id": "withdrawn", + from: "groupFrom", + card: "groupCard", + "withdrawal-id": "groupWithdrawn", category: "groupCategory", - tag: "tag", + tag: "groupTag", merchant: "groupMerchant", month: "groupmonth", week: "groupweek", diff --git a/src/libs/SearchParser/searchParser.peggy b/src/libs/SearchParser/searchParser.peggy index 3a8c8a1c4d2d..9b98446f11fd 100644 --- a/src/libs/SearchParser/searchParser.peggy +++ b/src/libs/SearchParser/searchParser.peggy @@ -29,11 +29,11 @@ // per-parser initializer (code executed before every parse) { const GROUP_BY_DEFAULT_SORT = { - from: "from", - card: "card", - "withdrawal-id": "withdrawn", + from: "groupFrom", + card: "groupCard", + "withdrawal-id": "groupWithdrawn", category: "groupCategory", - tag: "tag", + tag: "groupTag", merchant: "groupMerchant", month: "groupmonth", week: "groupweek", diff --git a/src/libs/SearchQueryUtils.ts b/src/libs/SearchQueryUtils.ts index e5d9fc517d26..79d9b6320aec 100644 --- a/src/libs/SearchQueryUtils.ts +++ b/src/libs/SearchQueryUtils.ts @@ -851,11 +851,14 @@ function buildFilterFormValuesFromQuery( filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM || filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.TO || filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.ASSIGNEE || - filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.EXPORTER || - filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.ATTENDEE + filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.EXPORTER ) { filtersForm[key as typeof filterKey] = filterValues.filter((id) => personalDetails?.[id]); } + if (filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.ATTENDEE) { + // Don't filter attendee values by personalDetails - they can be accountIDs OR display names for name-only attendees + filtersForm[key as typeof filterKey] = filterValues; + } if (filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.PAYER) { filtersForm[key as typeof filterKey] = filterValues.find((id) => personalDetails?.[id]); diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index 287e33075044..87026e8681d6 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -95,7 +95,7 @@ import {getDisplayNameOrDefault} from './PersonalDetailsUtils'; import { arePaymentsEnabled, canSendInvoice, - getCleanedTagName, + getCommaSeparatedTagNameWithSanitizedColons, getGroupPaidPoliciesWithExpenseChatEnabled, getPolicy, getSubmitToAccountID, @@ -109,9 +109,9 @@ import { isDeletedAction, isHoldAction, isMoneyRequestAction, - isReportActionVisible, isResolvedActionableWhisper, isWhisperActionTargetedToOthers, + shouldReportActionBeVisible, } from './ReportActionsUtils'; import {isExportAction} from './ReportPrimaryActionUtils'; import { @@ -454,7 +454,6 @@ type GetSectionsParams = { isOffline?: boolean; cardFeeds?: OnyxCollection; allTransactionViolations?: OnyxCollection; - visibleReportActionsData?: OnyxTypes.VisibleReportActionsDerivedValue; allReportMetadata: OnyxCollection; }; @@ -1851,7 +1850,7 @@ function createAndOpenSearchTransactionThread( * * Do not use directly, use only via `getSections()` facade. */ -function getReportActionsSections(data: OnyxTypes.SearchResults['data'], visibleReportActionsData?: OnyxTypes.VisibleReportActionsDerivedValue): [ReportActionListItemType[], number] { +function getReportActionsSections(data: OnyxTypes.SearchResults['data']): [ReportActionListItemType[], number] { const reportActionItems: ReportActionListItemType[] = []; const transactions = Object.keys(data) @@ -1870,13 +1869,11 @@ function getReportActionsSections(data: OnyxTypes.SearchResults['data'], visible for (const key in data) { if (isReportActionEntry(key)) { - const reportIDFromKey = key.replace(ONYXKEYS.COLLECTION.REPORT_ACTIONS, ''); const reportActions = Object.values(data[key]); n += reportActions.length; for (const reportAction of reportActions) { - const reportID = reportAction.reportID ?? reportIDFromKey; const from = reportAction.accountID ? (data.personalDetailsList?.[reportAction.accountID] ?? emptyPersonalDetails) : emptyPersonalDetails; - const report = data[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`] ?? {}; + const report = data[`${ONYXKEYS.COLLECTION.REPORT}${reportAction.reportID}`] ?? {}; const policy = data[`${ONYXKEYS.COLLECTION.POLICY}${report.policyID}`] ?? {}; const originalMessage = isMoneyRequestAction(reportAction) ? getOriginalMessage(reportAction) : undefined; const isSendingMoney = isMoneyRequestAction(reportAction) && originalMessage?.type === CONST.IOU.REPORT_ACTION_TYPE.PAY && originalMessage?.IOUDetails; @@ -1884,8 +1881,7 @@ function getReportActionsSections(data: OnyxTypes.SearchResults['data'], visible const invoiceReceiverPolicy: OnyxTypes.Policy | undefined = report?.invoiceReceiver?.type === CONST.REPORT.INVOICE_RECEIVER_TYPE.BUSINESS ? data[`${ONYXKEYS.COLLECTION.POLICY}${report.invoiceReceiver.policyID}`] : undefined; if ( - !reportID || - !isReportActionVisible(reportAction, reportID, canUserPerformWriteAction(report, isReportArchived), visibleReportActionsData) || + !shouldReportActionBeVisible(reportAction, reportAction.reportActionID, canUserPerformWriteAction(report, isReportArchived)) || isDeletedAction(reportAction) || isResolvedActionableWhisper(reportAction) || reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.CLOSED || @@ -2417,7 +2413,7 @@ function getTagSections(data: OnyxTypes.SearchResults['data'], queryJSON: Search // Format the tag name - use translated "No tag" for empty values so it sorts alphabetically const rawTag = tagGroup.tag; const isEmptyTag = !rawTag || rawTag === CONST.SEARCH.TAG_EMPTY_VALUE || rawTag === '(untagged)'; - const formattedTag = isEmptyTag ? translate('search.noTag') : getCleanedTagName(rawTag); + const formattedTag = isEmptyTag ? translate('search.noTag') : getCommaSeparatedTagNameWithSanitizedColons(rawTag); tagSections[key] = { groupedBy: CONST.SEARCH.GROUP_BY.TAG, @@ -2658,11 +2654,10 @@ function getSections({ isOffline, cardFeeds, allTransactionViolations, - visibleReportActionsData, allReportMetadata, }: GetSectionsParams) { if (type === CONST.SEARCH.DATA_TYPES.CHAT) { - return getReportActionsSections(data, visibleReportActionsData); + return getReportActionsSections(data); } if (type === CONST.SEARCH.DATA_TYPES.TASK) { return getTaskSections(data, formatPhoneNumber, archivedReportsIDList); diff --git a/src/libs/SidebarUtils.ts b/src/libs/SidebarUtils.ts index 2081c685aa56..d9147fe48b5e 100644 --- a/src/libs/SidebarUtils.ts +++ b/src/libs/SidebarUtils.ts @@ -5,17 +5,7 @@ import type {LocaleContextProps, LocalizedTranslate} from '@components/LocaleCon import type {PartialPolicyForSidebar, ReportsToDisplayInLHN} from '@hooks/useSidebarOrderedReports'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type { - Card, - PersonalDetails, - PersonalDetailsList, - ReportActions, - ReportAttributesDerivedValue, - ReportNameValuePairs, - Transaction, - TransactionViolation, - VisibleReportActionsDerivedValue, -} from '@src/types/onyx'; +import type {Card, PersonalDetails, PersonalDetailsList, ReportActions, ReportAttributesDerivedValue, ReportNameValuePairs, Transaction, TransactionViolation} from '@src/types/onyx'; import type Beta from '@src/types/onyx/Beta'; import type {ReportAttributes} from '@src/types/onyx/DerivedValues'; import type {Errors} from '@src/types/onyx/OnyxCommon'; @@ -663,7 +653,6 @@ function getOptionData({ movedFromReport, movedToReport, currentUserAccountID, - visibleReportActionsData, }: { report: OnyxEntry; oneTransactionThreadReport: OnyxEntry; @@ -683,7 +672,6 @@ function getOptionData({ movedFromReport?: OnyxEntry; movedToReport?: OnyxEntry; currentUserAccountID: number; - visibleReportActionsData?: VisibleReportActionsDerivedValue; }): OptionData | undefined { // When a user signs out, Onyx is cleared. Due to the lazy rendering with a virtual list, it's possible for // this method to be called after the Onyx data has been cleared out. In that case, it's fine to do @@ -814,18 +802,7 @@ function getOptionData({ const lastActorDisplayName = getLastActorDisplayName(lastActorDetails, currentUserAccountID); let lastMessageTextFromReport = lastMessageTextFromReportProp; if (!lastMessageTextFromReport) { - lastMessageTextFromReport = getLastMessageTextForReport({ - translate, - report, - lastActorDetails, - movedFromReport, - movedToReport, - policy, - isReportArchived, - visibleReportActionsDataParam: visibleReportActionsData, - lastAction, - currentUserAccountID, - }); + lastMessageTextFromReport = getLastMessageTextForReport({translate, report, lastActorDetails, movedFromReport, movedToReport, policy, isReportArchived, currentUserAccountID}); } // We need to remove sms domain in case the last message text has a phone number mention with sms domain. @@ -977,8 +954,7 @@ function getOptionData({ result.alternateText = getCardIssuedMessage({reportAction: lastAction, expensifyCard: card, translate}); } else if (lastAction?.actionName !== CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW && lastActorDisplayName && lastMessageTextFromReport) { const displayName = - (lastMessageTextFromReport.length > 0 && - getLastActorDisplayNameFromLastVisibleActions(report, lastActorDetails, currentUserAccountID, personalDetails, visibleReportActionsData, lastAction)) || + (lastMessageTextFromReport.length > 0 && getLastActorDisplayNameFromLastVisibleActions(report, lastActorDetails, currentUserAccountID, personalDetails)) || lastActorDisplayName; result.alternateText = formatReportLastMessageText(`${displayName}: ${lastMessageText}`); } else if (lastAction && isOldDotReportAction(lastAction)) { @@ -1047,8 +1023,7 @@ function getOptionData({ } if (shouldShowLastActorDisplayName(report, lastActorDetails, lastAction, currentUserAccountID) && !isReportArchived) { const displayName = - (lastMessageTextFromReport.length > 0 && - getLastActorDisplayNameFromLastVisibleActions(report, lastActorDetails, currentUserAccountID, personalDetails, visibleReportActionsData, lastAction)) || + (lastMessageTextFromReport.length > 0 && getLastActorDisplayNameFromLastVisibleActions(report, lastActorDetails, currentUserAccountID, personalDetails)) || lastActorDisplayName; result.alternateText = `${displayName}: ${formatReportLastMessageText(lastMessageText)}`; } else { diff --git a/src/libs/Sound/config/index.native.ts b/src/libs/Sound/config/index.native.ts deleted file mode 100644 index e948c1e189e8..000000000000 --- a/src/libs/Sound/config/index.native.ts +++ /dev/null @@ -1,3 +0,0 @@ -const config = {prefix: ''}; - -export default config; diff --git a/src/libs/Sound/config/index.ts b/src/libs/Sound/config/index.ts deleted file mode 100644 index f58755750d9d..000000000000 --- a/src/libs/Sound/config/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -const config = {prefix: '/sounds/'}; - -export default config; diff --git a/src/libs/Sound/index.native.ts b/src/libs/Sound/index.native.ts index f9e0db31b9b0..3a5feac6c53e 100644 --- a/src/libs/Sound/index.native.ts +++ b/src/libs/Sound/index.native.ts @@ -1,16 +1,41 @@ -import Sound from 'react-native-sound'; +import {createAudioPlayer, setAudioModeAsync} from 'expo-audio'; +import type {AudioSource} from 'expo-audio'; import type {ValueOf} from 'type-fest'; import {getIsMuted, SOUNDS, withMinimalExecutionTime} from './BaseSound'; -import config from './config'; + +// Sound assets must be required at compile time +const SOUND_ASSETS: Record, AudioSource> = { + [SOUNDS.DONE]: require('@assets/sounds/done.mp3') as AudioSource, + [SOUNDS.SUCCESS]: require('@assets/sounds/success.mp3') as AudioSource, + [SOUNDS.ATTENTION]: require('@assets/sounds/attention.mp3') as AudioSource, + [SOUNDS.RECEIVE]: require('@assets/sounds/receive.mp3') as AudioSource, +}; + +setAudioModeAsync({playsInSilentMode: false, shouldPlayInBackground: true}); const playSound = (soundFile: ValueOf) => { - const sound = new Sound(`${config.prefix}${soundFile}.mp3`, Sound.MAIN_BUNDLE, (error) => { - if (error || getIsMuted()) { + if (getIsMuted()) { + return; + } + + const source = SOUND_ASSETS[soundFile]; + if (!source) { + return; + } + + const player = createAudioPlayer(source); + + // Listen for playback completion to release resources + const subscription = player.addListener('playbackStatusUpdate', (status) => { + if (!status.didJustFinish) { return; } - sound.play(); + subscription.remove(); + player.remove(); }); + + player.play(); }; function clearSoundAssetsCache() {} diff --git a/src/libs/Sound/index.ts b/src/libs/Sound/index.ts index 39ff567df68e..795c5f8fd117 100644 --- a/src/libs/Sound/index.ts +++ b/src/libs/Sound/index.ts @@ -2,7 +2,8 @@ import {Howl} from 'howler'; import type {ValueOf} from 'type-fest'; import Log from '@libs/Log'; import {getIsMuted, SOUNDS, withMinimalExecutionTime} from './BaseSound'; -import config from './config'; + +const config = {prefix: '/sounds/'}; function cacheSoundAssets() { // Exit early if the Cache API is not available in the current browser. diff --git a/src/libs/Violations/ViolationsUtils.ts b/src/libs/Violations/ViolationsUtils.ts index ae6f496a22b5..1cab47d59084 100644 --- a/src/libs/Violations/ViolationsUtils.ts +++ b/src/libs/Violations/ViolationsUtils.ts @@ -455,7 +455,8 @@ const ViolationsUtils = { isControlPolicy; const shouldCategoryShowOverLimitViolation = canCalculateAmountViolations && !isInvoiceTransaction && typeof categoryOverLimit === 'number' && expenseAmount > categoryOverLimit && isControlPolicy; - const shouldShowMissingComment = !isInvoiceTransaction && policyCategories?.[categoryName ?? '']?.areCommentsRequired && !updatedTransaction.comment?.comment && isControlPolicy; + const shouldShowMissingComment = + !isInvoiceTransaction && policyCategories?.[categoryName ?? '']?.areCommentsRequired && !updatedTransaction.comment?.comment && isControlPolicy && policy?.areRulesEnabled; const attendees = updatedTransaction.modifiedAttendees ?? updatedTransaction.comment?.attendees ?? []; const isAttendeeTrackingEnabled = policy.isAttendeeTrackingEnabled ?? false; // Filter out the owner/creator when checking attendance count - expense is valid if at least one non-owner attendee is present @@ -501,13 +502,13 @@ const ViolationsUtils = { newTransactionViolations = reject(newTransactionViolations, {name: CONST.VIOLATIONS.FUTURE_DATE}); } - // Remove itemized receipt required violation if conditions are no longer met - if (canCalculateAmountViolations && hasItemizedReceiptRequiredViolation && !shouldShowItemizedReceiptRequiredViolation && !shouldShowCategoryItemizedReceiptRequiredViolation) { + // Remove itemized receipt required violation if it exists (will be re-added with updated data if still needed) + if (canCalculateAmountViolations && hasItemizedReceiptRequiredViolation) { newTransactionViolations = reject(newTransactionViolations, {name: CONST.VIOLATIONS.ITEMIZED_RECEIPT_REQUIRED}); } // Add itemized receipt required violation if conditions are met (policy or category level) - if (canCalculateAmountViolations && !hasItemizedReceiptRequiredViolation && (shouldShowItemizedReceiptRequiredViolation || shouldShowCategoryItemizedReceiptRequiredViolation)) { + if (canCalculateAmountViolations && (shouldShowItemizedReceiptRequiredViolation || shouldShowCategoryItemizedReceiptRequiredViolation)) { newTransactionViolations.push({ name: CONST.VIOLATIONS.ITEMIZED_RECEIPT_REQUIRED, data: @@ -520,23 +521,16 @@ const ViolationsUtils = { showInReview: true, }); } + // If itemized receipt is required, don't also show regular receipt required + const hasItemizedReceiptViolation = shouldShowItemizedReceiptRequiredViolation || shouldShowCategoryItemizedReceiptRequiredViolation; - // If itemized receipt is required (from server or client-side calculation), don't also show regular receipt required - const hasItemizedReceiptViolation = hasItemizedReceiptRequiredViolation || shouldShowItemizedReceiptRequiredViolation || shouldShowCategoryItemizedReceiptRequiredViolation; - - if ( - canCalculateAmountViolations && - ((hasReceiptRequiredViolation && (!shouldShowReceiptRequiredViolation || hasItemizedReceiptViolation)) || - (hasCategoryReceiptRequiredViolation && (!shouldShowCategoryReceiptRequiredViolation || hasItemizedReceiptViolation))) - ) { + // Remove receipt required violation if it exists (will be re-added with updated data if still needed) + if (canCalculateAmountViolations && (hasReceiptRequiredViolation || hasCategoryReceiptRequiredViolation)) { newTransactionViolations = reject(newTransactionViolations, {name: CONST.VIOLATIONS.RECEIPT_REQUIRED}); } - if ( - canCalculateAmountViolations && - !hasItemizedReceiptViolation && - ((!hasReceiptRequiredViolation && !!shouldShowReceiptRequiredViolation) || (!hasCategoryReceiptRequiredViolation && shouldShowCategoryReceiptRequiredViolation)) - ) { + // Add receipt required violation if conditions are met and itemized receipt is not required + if (canCalculateAmountViolations && !hasItemizedReceiptViolation && (shouldShowReceiptRequiredViolation || shouldShowCategoryReceiptRequiredViolation)) { newTransactionViolations.push({ name: CONST.VIOLATIONS.RECEIPT_REQUIRED, data: diff --git a/src/libs/actions/IOU/SendInvoice.ts b/src/libs/actions/IOU/SendInvoice.ts index 2b02631c8e8f..fd6b79837bea 100644 --- a/src/libs/actions/IOU/SendInvoice.ts +++ b/src/libs/actions/IOU/SendInvoice.ts @@ -8,8 +8,6 @@ import DateUtils from '@libs/DateUtils'; import {getMicroSecondOnyxErrorWithTranslationKey} from '@libs/ErrorUtils'; import {formatPhoneNumber} from '@libs/LocalePhoneNumber'; import Log from '@libs/Log'; -import isSearchTopmostFullScreenRoute from '@libs/Navigation/helpers/isSearchTopmostFullScreenRoute'; -import Navigation from '@libs/Navigation/Navigation'; import {getReportActionHtml, getReportActionText} from '@libs/ReportActionsUtils'; import type {OptimisticChatReport, OptimisticCreatedReportAction, OptimisticIOUReportAction} from '@libs/ReportUtils'; import { @@ -34,7 +32,15 @@ import type {InvoiceReceiver, InvoiceReceiverType} from '@src/types/onyx/Report' import type {OnyxData} from '@src/types/onyx/Request'; import type {Receipt} from '@src/types/onyx/Transaction'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; -import {getAllPersonalDetails, getPolicyTags, getReceiptError, getSearchOnyxUpdate, mergePolicyRecentlyUsedCategories, mergePolicyRecentlyUsedCurrencies} from '.'; +import { + getAllPersonalDetails, + getPolicyTags, + getReceiptError, + getSearchOnyxUpdate, + handleNavigateAfterExpenseCreate, + mergePolicyRecentlyUsedCategories, + mergePolicyRecentlyUsedCurrencies, +} from '.'; import type {BasePolicyParams} from '.'; type SendInvoiceInformation = { @@ -77,6 +83,7 @@ type SendInvoiceOptions = { companyWebsite?: string; policyRecentlyUsedCategories?: OnyxEntry; policyRecentlyUsedTags?: OnyxEntry; + isFromGlobalCreate?: boolean; }; type BuildOnyxDataForInvoiceParams = { @@ -734,6 +741,7 @@ function sendInvoice({ companyWebsite, policyRecentlyUsedCategories, policyRecentlyUsedTags, + isFromGlobalCreate, }: SendInvoiceOptions) { const parsedComment = getParsedComment(transaction?.comment?.comment?.trim() ?? ''); if (transaction?.comment) { @@ -798,11 +806,12 @@ function sendInvoice({ // eslint-disable-next-line @typescript-eslint/no-deprecated InteractionManager.runAfterInteractions(() => removeDraftTransaction(CONST.IOU.OPTIMISTIC_TRANSACTION_ID)); - if (isSearchTopmostFullScreenRoute()) { - Navigation.dismissModal(); - } else { - Navigation.dismissModalWithReport({reportID: invoiceRoom.reportID}); - } + handleNavigateAfterExpenseCreate({ + activeReportID: invoiceRoom.reportID, + transactionID, + isFromGlobalCreate, + isInvoice: true, + }); notifyNewAction(invoiceRoom.reportID, currentUserAccountID); } diff --git a/src/libs/actions/IOU/index.ts b/src/libs/actions/IOU/index.ts index 68c13e7b59d9..df6d6c2e044f 100644 --- a/src/libs/actions/IOU/index.ts +++ b/src/libs/actions/IOU/index.ts @@ -60,6 +60,7 @@ import Log from '@libs/Log'; import {validateAmount} from '@libs/MoneyRequestUtils'; import isReportOpenInRHP from '@libs/Navigation/helpers/isReportOpenInRHP'; import isReportOpenInSuperWideRHP from '@libs/Navigation/helpers/isReportOpenInSuperWideRHP'; +import isReportTopmostSplitNavigator from '@libs/Navigation/helpers/isReportTopmostSplitNavigator'; import isSearchTopmostFullScreenRoute from '@libs/Navigation/helpers/isSearchTopmostFullScreenRoute'; import Navigation, {navigationRef} from '@libs/Navigation/Navigation'; import {isOffline} from '@libs/Network/NetworkStore'; @@ -188,7 +189,7 @@ import { shouldEnableNegative, updateReportPreview, } from '@libs/ReportUtils'; -import {getCurrentSearchQueryJSON} from '@libs/SearchQueryUtils'; +import {buildCannedSearchQuery, getCurrentSearchQueryJSON} from '@libs/SearchQueryUtils'; import {getSuggestedSearches} from '@libs/SearchUIUtils'; import playSound, {SOUNDS} from '@libs/Sound'; import {shouldRestrictUserBillableActions} from '@libs/SubscriptionUtils'; @@ -232,7 +233,7 @@ import {buildOptimisticPolicyRecentlyUsedTags} from '@userActions/Policy/Tag'; import type {GuidedSetupData} from '@userActions/Report'; import {buildInviteToRoomOnyxData, completeOnboarding, notifyNewAction, optimisticReportLastData} from '@userActions/Report'; import {clearAllRelatedReportActionErrors} from '@userActions/ReportActions'; -import {sanitizeRecentWaypoints} from '@userActions/Transaction'; +import {mergeTransactionIdsHighlightOnSearchRoute, sanitizeRecentWaypoints} from '@userActions/Transaction'; import {removeDraftTransaction, removeDraftTransactions} from '@userActions/TransactionEdit'; import {getOnboardingMessages} from '@userActions/Welcome/OnboardingFlow'; import type {OnboardingCompanySize} from '@userActions/Welcome/OnboardingFlow'; @@ -274,6 +275,7 @@ type BaseTransactionParams = { billable?: boolean; reimbursable?: boolean; customUnitRateID?: string; + isFromGlobalCreate?: boolean; }; type InitMoneyRequestParams = { @@ -281,6 +283,7 @@ type InitMoneyRequestParams = { policy?: OnyxEntry; personalPolicy: Pick | undefined; isFromGlobalCreate?: boolean; + isFromFloatingActionButton?: boolean; currentIouRequestType?: IOURequestType | undefined; newIouRequestType: IOURequestType | undefined; report: OnyxEntry; @@ -695,6 +698,7 @@ type TrackExpenseTransactionParams = { isLinkedTrackedExpenseReportArchived?: boolean; odometerStart?: number; odometerEnd?: number; + isFromGlobalCreate?: boolean; gpsCoordinates?: string; }; @@ -1031,9 +1035,9 @@ function getPolicyTagsData(policyID: string | undefined) { * If the action is done from the report RHP, then we just want to dismiss the money request flow screens. * It is a helper function used only in this file. */ -function dismissModalAndOpenReportInInboxTab(reportID?: string) { +function dismissModalAndOpenReportInInboxTab(reportID?: string, isInvoice?: boolean) { const rootState = navigationRef.getRootState(); - if (isReportOpenInRHP(rootState)) { + if (!isInvoice && isReportOpenInRHP(rootState)) { const rhpKey = rootState.routes.at(-1)?.state?.key; if (rhpKey) { const hasMultipleTransactions = Object.values(allTransactions).filter((transaction) => transaction?.reportID === reportID).length > 0; @@ -1061,6 +1065,53 @@ function dismissModalAndOpenReportInInboxTab(reportID?: string) { Navigation.dismissModalWithReport({reportID}); } +/** + * Helper to navigate after an expense is created in order to standardize the post‑creation experience + * when creating an expense from the global create button. + * If the expense is created from the global create button then: + * - If it is created on the inbox tab, it will open the chat report containing that expense. + * - If it is created elsewhere, it will navigate to Reports > Expense and highlight the newly created expense. + */ +function handleNavigateAfterExpenseCreate({ + activeReportID, + transactionID, + isFromGlobalCreate, + isInvoice, + shouldHandleNavigation = true, +}: { + activeReportID?: string; + transactionID?: string; + isFromGlobalCreate?: boolean; + isInvoice?: boolean; + shouldHandleNavigation?: boolean; +}) { + const isUserOnInbox = isReportTopmostSplitNavigator(); + + // If the expense is not created from global create or is currently on the inbox tab, + // we just need to dismiss the money request flow screens + // and open the report chat containing the IOU report + if (!isFromGlobalCreate || isUserOnInbox || !transactionID) { + if (shouldHandleNavigation) { + dismissModalAndOpenReportInInboxTab(activeReportID, isInvoice); + } + return; + } + + const type = isInvoice ? CONST.SEARCH.DATA_TYPES.INVOICE : CONST.SEARCH.DATA_TYPES.EXPENSE; + + // We mark this transaction to be highlighted when opening the expense search route page + mergeTransactionIdsHighlightOnSearchRoute(type, {[transactionID]: true}); + + if (!shouldHandleNavigation) { + return; + } + const queryString = buildCannedSearchQuery({type}); + Navigation.dismissModal(); + Navigation.setNavigationActionToMicrotaskQueue(() => { + Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query: queryString})); + }); +} + /** * Build a minimal transaction record for formula computation in buildOptimisticExpenseReport. * This allows formulas like {report:startdate}, {report:expensescount} to work correctly. @@ -1114,6 +1165,7 @@ function initMoneyRequest({ policy, personalPolicy, isFromGlobalCreate, + isFromFloatingActionButton, currentIouRequestType, newIouRequestType, report, @@ -1142,6 +1194,7 @@ function initMoneyRequest({ Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${newTransactionID}`, { reportID, isFromGlobalCreate, + isFromFloatingActionButton, created, currency, transactionID: newTransactionID, @@ -1215,6 +1268,7 @@ function initMoneyRequest({ reportID, transactionID: newTransactionID, isFromGlobalCreate, + isFromFloatingActionButton, merchant: defaultMerchant, }; @@ -1249,6 +1303,7 @@ function startMoneyRequest( skipConfirmation = false, backToReport?: string, draftTransactions?: OnyxCollection, + isFromFloatingActionButton?: boolean, ) { Performance.markStart(CONST.TIMING.OPEN_CREATE_EXPENSE); const sourceRoute = Navigation.getActiveRoute(); @@ -1263,6 +1318,9 @@ function startMoneyRequest( }, }); clearMoneyRequest(CONST.IOU.OPTIMISTIC_TRANSACTION_ID, skipConfirmation, draftTransactions); + if (isFromFloatingActionButton) { + Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${CONST.IOU.OPTIMISTIC_TRANSACTION_ID}`, {isFromFloatingActionButton}); + } switch (requestType) { case CONST.IOU.REQUEST_TYPE.MANUAL: Navigation.navigate(ROUTES.MONEY_REQUEST_CREATE_TAB_MANUAL.getRoute(CONST.IOU.ACTION.CREATE, iouType, CONST.IOU.OPTIMISTIC_TRANSACTION_ID, reportID, backToReport)); @@ -1281,8 +1339,18 @@ function startMoneyRequest( } } -function startDistanceRequest(iouType: ValueOf, reportID: string, requestType?: IOURequestType, skipConfirmation = false, backToReport?: string) { +function startDistanceRequest( + iouType: ValueOf, + reportID: string, + requestType?: IOURequestType, + skipConfirmation = false, + backToReport?: string, + isFromFloatingActionButton?: boolean, +) { clearMoneyRequest(CONST.IOU.OPTIMISTIC_TRANSACTION_ID, skipConfirmation); + if (isFromFloatingActionButton) { + Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${CONST.IOU.OPTIMISTIC_TRANSACTION_ID}`, {isFromFloatingActionButton}); + } switch (requestType) { case CONST.IOU.REQUEST_TYPE.DISTANCE_MAP: Navigation.navigate(ROUTES.DISTANCE_REQUEST_CREATE_TAB_MAP.getRoute(CONST.IOU.ACTION.CREATE, iouType, CONST.IOU.OPTIMISTIC_TRANSACTION_ID, reportID, backToReport)); @@ -4518,7 +4586,7 @@ function getUpdateMoneyRequestParams(params: GetUpdateMoneyRequestParamsType): U value: lodashUnionBy( transactionChanges.attendees?.map(({avatarUrl, displayName, email}) => ({avatarUrl, displayName, email})), recentAttendees, - 'email', + (attendee) => attendee.email || attendee.displayName, ).slice(0, CONST.IOU.MAX_RECENT_REPORTS_TO_SHOW), }); } @@ -4968,6 +5036,7 @@ function updateMoneyRequestDate({ removeTransactionFromDuplicateTransactionViolation(data.onyxData, transactionID, transactions, transactionViolations); } const {params, onyxData} = data; + notifyNewAction(Navigation.getSearchTopmostReportId(), currentUserAccountIDParam); API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST_DATE, params, onyxData); } @@ -5115,6 +5184,7 @@ function updateMoneyRequestMerchant({ }); } const {params, onyxData} = data; + notifyNewAction(Navigation.getSearchTopmostReportId(), currentUserAccountIDParam); API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST_MERCHANT, params, onyxData); } @@ -5217,6 +5287,7 @@ function updateMoneyRequestTag({ isASAPSubmitBetaEnabled, iouReportNextStep: parentReportNextStep, }); + notifyNewAction(Navigation.getSearchTopmostReportId(), currentUserAccountIDParam); API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST_TAG, params, onyxData); } @@ -5263,6 +5334,7 @@ function updateMoneyRequestTaxAmount({ iouReportNextStep: parentReportNextStep, }); API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST_TAX_AMOUNT, params, onyxData); + notifyNewAction(Navigation.getSearchTopmostReportId(), currentUserAccountIDParam); } type UpdateMoneyRequestTaxRateParams = { @@ -5317,6 +5389,7 @@ function updateMoneyRequestTaxRate({ }); API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST_TAX_RATE, params, onyxData); + notifyNewAction(Navigation.getSearchTopmostReportId(), currentUserAccountIDParam); } type UpdateMoneyRequestDistanceParams = { @@ -5488,6 +5561,7 @@ function updateMoneyRequestCategory({ hash, iouReportNextStep: parentReportNextStep, }); + notifyNewAction(Navigation.getSearchTopmostReportId(), currentUserAccountIDParam); API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST_CATEGORY, params, onyxData); } @@ -5542,6 +5616,7 @@ function updateMoneyRequestDescription({ } const {params, onyxData} = data; params.description = parsedComment; + notifyNewAction(Navigation.getSearchTopmostReportId(), currentUserAccountIDParam); API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST_DESCRIPTION, params, onyxData); } @@ -5615,6 +5690,7 @@ function updateMoneyRequestDistanceRate({ // `taxAmount` & `taxCode` only needs to be updated in the optimistic data, so we need to remove them from the params const {taxAmount, taxCode, ...paramsWithoutTaxUpdated} = params; API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST_DISTANCE_RATE, paramsWithoutTaxUpdated, onyxData); + notifyNewAction(Navigation.getSearchTopmostReportId(), currentUserAccountIDParam); } const getConvertTrackedExpenseInformation = ( @@ -6249,6 +6325,7 @@ function requestMoney(requestMoneyInformation: RequestMoneyInformation): {iouRep count, rate, unit, + isFromGlobalCreate, } = transactionParams; const testDriveCommentReportActionID = isTestDrive ? NumberUtils.rand64() : undefined; @@ -6446,9 +6523,6 @@ function requestMoney(requestMoneyInformation: RequestMoneyInformation): {iouRep if (shouldHandleNavigation) { // eslint-disable-next-line @typescript-eslint/no-deprecated InteractionManager.runAfterInteractions(() => removeDraftTransactions()); - if (!requestMoneyInformation.isRetry) { - dismissModalAndOpenReportInInboxTab(backToReport ?? activeReportID); - } const trackReport = Navigation.getReportRouteByID(linkedTrackedExpenseReportAction?.childReportID); if (trackReport?.key) { @@ -6456,6 +6530,15 @@ function requestMoney(requestMoneyInformation: RequestMoneyInformation): {iouRep } } + if (!requestMoneyInformation.isRetry) { + handleNavigateAfterExpenseCreate({ + activeReportID: backToReport ?? activeReportID, + transactionID: transaction.transactionID, + isFromGlobalCreate, + shouldHandleNavigation, + }); + } + if (activeReportID && !isMoneyRequestReport) { Navigation.setNavigationActionToMicrotaskQueue(() => setTimeout(() => { @@ -6485,7 +6568,7 @@ function submitPerDiemExpense(submitPerDiemExpenseInformation: PerDiemExpenseInf policyRecentlyUsedCurrencies, } = submitPerDiemExpenseInformation; const {payeeAccountID} = participantParams; - const {currency, comment = '', category, tag, created, customUnit, attendees} = transactionParams; + const {currency, comment = '', category, tag, created, customUnit, attendees, isFromGlobalCreate} = transactionParams; if ( isEmptyObject(policyParams.policy) || @@ -6571,7 +6654,7 @@ function submitPerDiemExpense(submitPerDiemExpenseInformation: PerDiemExpenseInf // eslint-disable-next-line @typescript-eslint/no-deprecated InteractionManager.runAfterInteractions(() => removeDraftTransaction(CONST.IOU.OPTIMISTIC_TRANSACTION_ID)); - dismissModalAndOpenReportInInboxTab(activeReportID); + handleNavigateAfterExpenseCreate({activeReportID, transactionID: transaction.transactionID, isFromGlobalCreate}); if (activeReportID) { notifyNewAction(activeReportID, payeeAccountID); @@ -6627,6 +6710,7 @@ function trackExpense(params: CreateTrackExpenseParams) { attendees, odometerStart, odometerEnd, + isFromGlobalCreate, gpsCoordinates, } = transactionData; const isMoneyRequestReport = isMoneyRequestReportReportUtils(report); @@ -6890,7 +6974,8 @@ function trackExpense(params: CreateTrackExpenseParams) { reportPreviewReportActionID: reportPreviewAction?.reportActionID, optimisticReportID, optimisticReportActionID, - policyID: isDistanceRequest ? undefined : policy?.id, + // Tracked expenses in the CREATE flow are unreported and not tied to a policy + policyID: undefined, receipt: isFileUploadable(trackedReceipt) ? trackedReceipt : undefined, receiptState: trackedReceipt?.state, reimbursable, @@ -6920,10 +7005,15 @@ function trackExpense(params: CreateTrackExpenseParams) { if (shouldHandleNavigation) { // eslint-disable-next-line @typescript-eslint/no-deprecated InteractionManager.runAfterInteractions(() => removeDraftTransactions()); + } - if (!params.isRetry) { - dismissModalAndOpenReportInInboxTab(activeReportID); - } + if (!params.isRetry) { + handleNavigateAfterExpenseCreate({ + activeReportID, + transactionID: transaction?.transactionID, + isFromGlobalCreate, + shouldHandleNavigation, + }); } notifyNewAction(activeReportID, payeeAccountID); @@ -7567,6 +7657,7 @@ function createDistanceRequest(distanceRequestInformation: CreateDistanceRequest receipt, odometerStart, odometerEnd, + isFromGlobalCreate, gpsCoordinates, } = transactionParams; @@ -7773,7 +7864,7 @@ function createDistanceRequest(distanceRequestInformation: CreateDistanceRequest const activeReportID = isMoneyRequestReport && report?.reportID ? report.reportID : parameters.chatReportID; if (shouldHandleNavigation) { - dismissModalAndOpenReportInInboxTab(backToReport ?? activeReportID); + handleNavigateAfterExpenseCreate({activeReportID: backToReport ?? activeReportID, isFromGlobalCreate, transactionID: parameters.transactionID}); } if (!isMoneyRequestReport) { @@ -7856,6 +7947,7 @@ function updateMoneyRequestAmountAndCurrency({ removeTransactionFromDuplicateTransactionViolation(data.onyxData, transactionID, transactions, transactionViolations); } const {params, onyxData} = data; + notifyNewAction(Navigation.getSearchTopmostReportId(), currentUserAccountIDParam); API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST_AMOUNT_AND_CURRENCY, params, onyxData); } @@ -12681,7 +12773,7 @@ function markRejectViolationAsResolved(transactionID: string, reportID?: string) function initSplitExpenseItemData( transaction: OnyxEntry, - {amount, transactionID, reportID, created}: {amount?: number; transactionID?: string; reportID?: string; created?: string} = {}, + {amount, transactionID, reportID, created, isManuallyEdited}: {amount?: number; transactionID?: string; reportID?: string; created?: string; isManuallyEdited?: boolean} = {}, ): SplitExpense { const transactionDetails = getTransactionDetails(transaction); const currentReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${transaction?.reportID}`]; @@ -12697,6 +12789,7 @@ function initSplitExpenseItemData( statusNum: currentReport?.statusNum ?? 0, reportID: reportID ?? transaction?.reportID ?? String(CONST.DEFAULT_NUMBER_ID), reimbursable: transactionDetails?.reimbursable, + isManuallyEdited: isManuallyEdited ?? false, }; } @@ -12716,7 +12809,8 @@ function initSplitExpense(transactions: OnyxCollection, r if (isExpenseSplit) { const relatedTransactions = getChildTransactions(transactions, reports, originalTransactionID); const transactionDetails = getTransactionDetails(originalTransaction); - const splitExpenses = relatedTransactions.map((currentTransaction) => initSplitExpenseItemData(currentTransaction)); + // Mark existing child transactions as manually edited (locked) since we're editing existing splits + const splitExpenses = relatedTransactions.map((currentTransaction) => initSplitExpenseItemData(currentTransaction, {isManuallyEdited: true})); const draftTransaction = buildOptimisticTransaction({ originalTransactionID, transactionParams: { @@ -12744,9 +12838,18 @@ function initSplitExpense(transactions: OnyxCollection, r const transactionDetails = getTransactionDetails(transaction); const transactionDetailsAmount = transactionDetails?.amount ?? 0; + // New splits start as unedited (isManuallyEdited: false) so they participate in auto-redistribution const splitExpenses = [ - initSplitExpenseItemData(transaction, {amount: calculateIOUAmount(1, transactionDetailsAmount, transactionDetails?.currency ?? '', false), transactionID: NumberUtils.rand64()}), - initSplitExpenseItemData(transaction, {amount: calculateIOUAmount(1, transactionDetailsAmount, transactionDetails?.currency ?? '', true), transactionID: NumberUtils.rand64()}), + initSplitExpenseItemData(transaction, { + amount: calculateIOUAmount(1, transactionDetailsAmount, transactionDetails?.currency ?? '', false), + transactionID: NumberUtils.rand64(), + isManuallyEdited: false, + }), + initSplitExpenseItemData(transaction, { + amount: calculateIOUAmount(1, transactionDetailsAmount, transactionDetails?.currency ?? '', true), + transactionID: NumberUtils.rand64(), + isManuallyEdited: false, + }), ]; const draftTransaction = buildOptimisticTransaction({ @@ -12810,22 +12913,54 @@ function initDraftSplitExpenseDataForEdit(draftTransaction: OnyxEntry, draftTransaction: OnyxEntry) { if (!transaction || !draftTransaction) { return; } - Onyx.merge(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transaction.transactionID}`, { + const newTransactionID = NumberUtils.rand64(); + const newSplit = initSplitExpenseItemData(transaction, { + amount: 0, + transactionID: newTransactionID, + reportID: draftTransaction?.reportID, + isManuallyEdited: false, + }); + + const existingSplits = draftTransaction.comment?.splitExpenses ?? []; + const updatedSplitExpenses = [...existingSplits, newSplit]; + + // Get total amount and currency for redistribution + const total = getAmount(draftTransaction, undefined, undefined, true, true); + const currency = getCurrency(draftTransaction); + const originalTransactionID = draftTransaction.comment?.originalTransactionID ?? transaction.transactionID; + + // Calculate sum of manually edited splits + const editedSum = updatedSplitExpenses.filter((split) => split.isManuallyEdited).reduce((sum, split) => sum + split.amount, 0); + + // Find all unedited splits (including the new one) + const uneditedSplits = updatedSplitExpenses.filter((split) => !split.isManuallyEdited); + const uneditedCount = uneditedSplits.length; + + // Redistribute remaining amount among unedited splits + const remaining = total - editedSum; + const lastUneditedIndex = uneditedCount - 1; + let uneditedIndex = 0; + + const redistributedSplitExpenses = updatedSplitExpenses.map((split) => { + if (split.isManuallyEdited) { + return split; + } + const isLast = uneditedIndex === lastUneditedIndex; + const newAmount = calculateIOUAmount(lastUneditedIndex, remaining, currency, isLast, true); + uneditedIndex += 1; + return {...split, amount: newAmount}; + }); + + Onyx.merge(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${originalTransactionID}`, { comment: { - splitExpenses: [ - ...(draftTransaction.comment?.splitExpenses ?? []), - initSplitExpenseItemData(transaction, { - amount: 0, - transactionID: NumberUtils.rand64(), - reportID: draftTransaction?.reportID, - }), - ], + splitExpenses: redistributedSplitExpenses, splitsStartDate: null, splitsEndDate: null, }, @@ -12864,6 +12999,8 @@ function evenlyDistributeSplitExpenseAmounts(draftTransaction: OnyxEntry ({ ...splitExpense, amount: calculateIOUAmount(splitCount - 1, total, currency, index === lastIndex, true), + // Reset isManuallyEdited since user explicitly requested even distribution + isManuallyEdited: false, })); Onyx.merge(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${originalTransactionID}`, { @@ -12978,19 +13115,57 @@ function updateSplitExpenseAmountField(draftTransaction: OnyxEntry { + const splitExpenses = draftTransaction.comment?.splitExpenses ?? []; + const originalTransactionID = draftTransaction.comment?.originalTransactionID; + const total = getAmount(draftTransaction, undefined, undefined, true, true); + const currency = getCurrency(draftTransaction); + + // Mark the edited split and update its amount + const splitWithUpdatedAmount = splitExpenses.map((splitExpense) => { if (splitExpense.transactionID === currentItemTransactionID) { return { ...splitExpense, amount, + isManuallyEdited: true, }; } return splitExpense; }); - Onyx.merge(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${draftTransaction?.comment?.originalTransactionID}`, { + // Find unedited splits (excluding the one being edited) + const uneditedSplits = splitWithUpdatedAmount.filter((split) => !split.isManuallyEdited); + + // If no unedited splits remain, just save the updated amounts without redistribution + if (uneditedSplits.length === 0) { + Onyx.merge(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${originalTransactionID}`, { + comment: { + splitExpenses: splitWithUpdatedAmount, + }, + }); + return; + } + + // Sum amounts of manually edited splits (the updated split is already marked as edited) + const editedSum = splitWithUpdatedAmount.filter((split) => split.isManuallyEdited).reduce((sum, split) => sum + split.amount, 0); + + // Redistribute remaining amount among unedited splits + const remaining = total - editedSum; + const lastUneditedIndex = uneditedSplits.length - 1; + let uneditedIndex = 0; + + const redistributedSplitExpenses = splitWithUpdatedAmount.map((split) => { + if (split.isManuallyEdited) { + return split; + } + const isLast = uneditedIndex === lastUneditedIndex; + const newAmount = calculateIOUAmount(lastUneditedIndex, remaining, currency, isLast, true); + uneditedIndex += 1; + return {...split, amount: newAmount}; + }); + + Onyx.merge(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${originalTransactionID}`, { comment: { - splitExpenses: updatedSplitExpenses, + splitExpenses: redistributedSplitExpenses, }, }); } @@ -13367,6 +13542,7 @@ export { getPolicyTags, setMoneyRequestTimeRate, setMoneyRequestTimeCount, + handleNavigateAfterExpenseCreate, buildMinimalTransactionForFormula, buildOnyxDataForMoneyRequest, createSplitsAndOnyxData, diff --git a/src/libs/actions/OnyxDerived/ONYX_DERIVED_VALUES.ts b/src/libs/actions/OnyxDerived/ONYX_DERIVED_VALUES.ts index 716256354fc2..af219af05716 100644 --- a/src/libs/actions/OnyxDerived/ONYX_DERIVED_VALUES.ts +++ b/src/libs/actions/OnyxDerived/ONYX_DERIVED_VALUES.ts @@ -5,7 +5,6 @@ import nonPersonalAndWorkspaceCardListConfig from './configs/nonPersonalAndWorks import outstandingReportsByPolicyIDConfig from './configs/outstandingReportsByPolicyID'; import reportAttributesConfig from './configs/reportAttributes'; import reportTransactionsAndViolationsConfig from './configs/reportTransactionsAndViolations'; -import visibleReportActionsConfig from './configs/visibleReportActions'; import type {OnyxDerivedValueConfig} from './types'; /** @@ -16,7 +15,6 @@ const ONYX_DERIVED_VALUES = { [ONYXKEYS.DERIVED.REPORT_ATTRIBUTES]: reportAttributesConfig, [ONYXKEYS.DERIVED.REPORT_TRANSACTIONS_AND_VIOLATIONS]: reportTransactionsAndViolationsConfig, [ONYXKEYS.DERIVED.OUTSTANDING_REPORTS_BY_POLICY_ID]: outstandingReportsByPolicyIDConfig, - [ONYXKEYS.DERIVED.VISIBLE_REPORT_ACTIONS]: visibleReportActionsConfig, [ONYXKEYS.DERIVED.NON_PERSONAL_AND_WORKSPACE_CARD_LIST]: nonPersonalAndWorkspaceCardListConfig, [ONYXKEYS.DERIVED.CARD_FEED_ERRORS]: cardFeedErrorsConfig, } as const satisfies { diff --git a/src/libs/actions/OnyxDerived/configs/visibleReportActions.ts b/src/libs/actions/OnyxDerived/configs/visibleReportActions.ts deleted file mode 100644 index e69e5e661c11..000000000000 --- a/src/libs/actions/OnyxDerived/configs/visibleReportActions.ts +++ /dev/null @@ -1,151 +0,0 @@ -import type {OnyxEntry} from 'react-native-onyx'; -import {isActionableWhisperRequiringWritePermission, isConciergeCategoryOptions, isMovedTransactionAction, shouldReportActionBeVisible} from '@libs/ReportActionsUtils'; -import createOnyxDerivedValueConfig from '@userActions/OnyxDerived/createOnyxDerivedValueConfig'; -import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; -import type {ReportAction, ReportActions} from '@src/types/onyx'; -import type {VisibleReportActionsDerivedValue} from '@src/types/onyx/DerivedValues'; - -function getOrCreateReportVisibilityRecord(result: VisibleReportActionsDerivedValue, reportID: string): Record { - if (!result[reportID]) { - // eslint-disable-next-line no-param-reassign - result[reportID] = {}; - } - return result[reportID]; -} - -function doesActionDependOnReportExistence(action: ReportAction): boolean { - const isUnreportedTransaction = action.actionName === CONST.REPORT.ACTIONS.TYPE.UNREPORTED_TRANSACTION; - const isMovedTransaction = isMovedTransactionAction(action as OnyxEntry); - - return isUnreportedTransaction || isMovedTransaction; -} - -/** - * Returns true if the action's visibility depends on runtime context that can't be cached, - * such as write permissions or policy settings. - */ -function shouldSkipCachingAction(action: ReportAction): boolean { - return isActionableWhisperRequiringWritePermission(action) || isConciergeCategoryOptions(action); -} - -export default createOnyxDerivedValueConfig({ - key: ONYXKEYS.DERIVED.VISIBLE_REPORT_ACTIONS, - // Note: REPORT and SESSION dependencies are needed to trigger recompute when reports change - // (for UNREPORTED_TRANSACTION/MOVED_TRANSACTION visibility) or when user changes (for whisper targeting). - // shouldReportActionBeVisible uses global Onyx-connected variables internally. - dependencies: [ONYXKEYS.COLLECTION.REPORT_ACTIONS, ONYXKEYS.COLLECTION.REPORT, ONYXKEYS.SESSION], - compute: ([allReportActions], {sourceValues, currentValue}): VisibleReportActionsDerivedValue => { - if (!allReportActions) { - return {}; - } - - const reportActionsUpdates = sourceValues?.[ONYXKEYS.COLLECTION.REPORT_ACTIONS]; - const reportUpdates = sourceValues?.[ONYXKEYS.COLLECTION.REPORT]; - const sessionUpdates = sourceValues?.[ONYXKEYS.SESSION]; - - // Session change = user changed, need full recompute due to whisper targeting - if (sessionUpdates) { - const result: VisibleReportActionsDerivedValue = {}; - - for (const [reportActionsKey, reportActions] of Object.entries(allReportActions)) { - if (!reportActions) { - continue; - } - - const reportID = reportActionsKey.replace(ONYXKEYS.COLLECTION.REPORT_ACTIONS, ''); - const reportVisibility = getOrCreateReportVisibilityRecord(result, reportID); - - for (const [actionID, action] of Object.entries(reportActions)) { - if (action) { - if (shouldSkipCachingAction(action)) { - continue; - } - reportVisibility[action.reportActionID] = shouldReportActionBeVisible(action, actionID); - } - } - } - - return result; - } - - const result: VisibleReportActionsDerivedValue = currentValue ? {...currentValue} : {}; - - // Reports changed - recompute actions that depend on report existence - if (reportUpdates) { - for (const [reportActionsKey, reportActions] of Object.entries(allReportActions)) { - if (!reportActions) { - continue; - } - - const reportID = reportActionsKey.replace(ONYXKEYS.COLLECTION.REPORT_ACTIONS, ''); - const reportVisibility = getOrCreateReportVisibilityRecord(result, reportID); - - for (const [actionID, action] of Object.entries(reportActions)) { - if (!action) { - continue; - } - - if (doesActionDependOnReportExistence(action)) { - if (shouldSkipCachingAction(action)) { - delete reportVisibility[actionID]; - delete reportVisibility[action.reportActionID]; - continue; - } - reportVisibility[action.reportActionID] = shouldReportActionBeVisible(action, actionID); - if (actionID !== action.reportActionID) { - delete reportVisibility[actionID]; - } - } - } - } - - if (!reportActionsUpdates) { - return result; - } - } - - const reportActionsToProcess = reportActionsUpdates ? Object.keys(reportActionsUpdates) : Object.keys(allReportActions); - - for (const reportActionsKey of reportActionsToProcess) { - const reportActions: OnyxEntry = allReportActions[reportActionsKey]; - const reportID = reportActionsKey.replace(ONYXKEYS.COLLECTION.REPORT_ACTIONS, ''); - - if (!reportActions) { - delete result[reportID]; - continue; - } - - const reportVisibility = getOrCreateReportVisibilityRecord(result, reportID); - - const specificUpdates = reportActionsUpdates?.[reportActionsKey]; - const actionIDsToProcess = specificUpdates ? Object.keys(specificUpdates) : Object.keys(reportActions); - - for (const actionID of actionIDsToProcess) { - if (specificUpdates && specificUpdates[actionID] === null) { - delete reportVisibility[actionID]; - continue; - } - - const action = reportActions[actionID]; - if (!action) { - delete reportVisibility[actionID]; - continue; - } - - if (shouldSkipCachingAction(action)) { - delete reportVisibility[actionID]; - delete reportVisibility[action.reportActionID]; - continue; - } - - reportVisibility[action.reportActionID] = shouldReportActionBeVisible(action, actionID); - if (actionID !== action.reportActionID) { - delete reportVisibility[actionID]; - } - } - } - - return result; - }, -}); diff --git a/src/libs/actions/Policy/Policy.ts b/src/libs/actions/Policy/Policy.ts index e2d4aca81a82..ce74c26c9b40 100644 --- a/src/libs/actions/Policy/Policy.ts +++ b/src/libs/actions/Policy/Policy.ts @@ -6,6 +6,7 @@ import Onyx from 'react-native-onyx'; import type {TupleToUnion, ValueOf} from 'type-fest'; import type {ReportExportType} from '@components/ButtonWithDropdownMenu/types'; import type {LocaleContextProps} from '@components/LocaleContextProvider'; +import type PolicyData from '@hooks/usePolicyData/types'; import * as API from '@libs/API'; import type { AddBillingCardAndRequestWorkspaceOwnerChangeParams, @@ -2837,7 +2838,7 @@ function buildDuplicatePolicyData(policy: Policy, options: DuplicatePolicyDataOp const isTaxesOptionSelected = parts?.taxes; const isTagsOptionSelected = parts?.tags; const isInvoicesOptionSelected = parts?.invoices; - const isCustomUnitsOptionSelected = parts?.customUnits; + const isDistanceRatesOptionSelected = parts?.distance; const isRulesOptionSelected = parts?.expenses; const isWorkflowsOptionSelected = parts?.exportLayouts; const isPerDiemOptionSelected = parts?.perDiem; @@ -2867,7 +2868,7 @@ function buildDuplicatePolicyData(policy: Policy, options: DuplicatePolicyDataOp ...policy, areCategoriesEnabled: true, areTagsEnabled: isTagsOptionSelected, - areDistanceRatesEnabled: isCustomUnitsOptionSelected, + areDistanceRatesEnabled: isDistanceRatesOptionSelected, areInvoicesEnabled: isInvoicesOptionSelected, areRulesEnabled: isRulesOptionSelected, areWorkflowsEnabled: isWorkflowsOptionSelected, @@ -2883,7 +2884,7 @@ function buildDuplicatePolicyData(policy: Policy, options: DuplicatePolicyDataOp name: policyName, fieldList: isReportsOptionSelected ? policy?.fieldList : undefined, connections: isConnectionsOptionSelected ? policy?.connections : undefined, - customUnits: getCustomUnitsForDuplication(policy, isCustomUnitsOptionSelected, isPerDiemOptionSelected), + customUnits: getCustomUnitsForDuplication(policy, isDistanceRatesOptionSelected, isPerDiemOptionSelected), taxRates: isTaxesOptionSelected ? policy?.taxRates : undefined, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, pendingFields: { @@ -4571,7 +4572,7 @@ const DISABLED_MAX_EXPENSE_VALUES: Pick startMoneyRequest(CONST.IOU.TYPE.SUBMIT, reportID, requestType, true), true); + selectOption(() => startMoneyRequest(CONST.IOU.TYPE.SUBMIT, reportID, requestType, true, undefined, undefined, isFromFloatingActionButton), true); break; case CONST.QUICK_ACTIONS.SPLIT_MANUAL: case CONST.QUICK_ACTIONS.SPLIT_SCAN: case CONST.QUICK_ACTIONS.SPLIT_DISTANCE: - selectOption(() => startMoneyRequest(CONST.IOU.TYPE.SPLIT, reportID, requestType, true), true); + selectOption(() => startMoneyRequest(CONST.IOU.TYPE.SPLIT, reportID, requestType, true, undefined, undefined, isFromFloatingActionButton), true); break; case CONST.QUICK_ACTIONS.SEND_MONEY: - selectOption(() => startMoneyRequest(CONST.IOU.TYPE.PAY, reportID, undefined, true), false); + selectOption(() => startMoneyRequest(CONST.IOU.TYPE.PAY, reportID, undefined, true, undefined, undefined, isFromFloatingActionButton), false); break; case CONST.QUICK_ACTIONS.ASSIGN_TASK: selectOption(() => startOutCreateTaskQuickAction(currentUserAccountID, isValidReport ? reportID : '', targetAccountPersonalDetails), false); break; case CONST.QUICK_ACTIONS.TRACK_MANUAL: case CONST.QUICK_ACTIONS.TRACK_SCAN: - selectOption(() => startMoneyRequest(CONST.IOU.TYPE.TRACK, reportID, requestType, true), false); + selectOption(() => startMoneyRequest(CONST.IOU.TYPE.TRACK, reportID, requestType, true, undefined, undefined, isFromFloatingActionButton), false); break; case CONST.QUICK_ACTIONS.REQUEST_DISTANCE: - selectOption(() => startDistanceRequest(CONST.IOU.TYPE.SUBMIT, reportID, requestType, true), false); + selectOption(() => startDistanceRequest(CONST.IOU.TYPE.SUBMIT, reportID, requestType, true, undefined, isFromFloatingActionButton), false); break; case CONST.QUICK_ACTIONS.TRACK_DISTANCE: - selectOption(() => startDistanceRequest(CONST.IOU.TYPE.TRACK, reportID, requestType, true), false); + selectOption(() => startDistanceRequest(CONST.IOU.TYPE.TRACK, reportID, requestType, true, undefined, isFromFloatingActionButton), false); break; default: } diff --git a/src/libs/actions/Report/index.ts b/src/libs/actions/Report/index.ts index 7b5f2e4b57dd..eb3a15dd5947 100644 --- a/src/libs/actions/Report/index.ts +++ b/src/libs/actions/Report/index.ts @@ -220,7 +220,6 @@ import type { ReportUserIsTyping, Transaction, TransactionViolations, - VisibleReportActionsDerivedValue, } from '@src/types/onyx'; import type {Decision} from '@src/types/onyx/OriginalMessage'; import type {CurrentUserPersonalDetails, Timezone} from '@src/types/onyx/PersonalDetails'; @@ -1264,6 +1263,7 @@ function openReport( [iouReportActionID]: { ...optimisticIOUAction, childReportID: reportID, + isOptimisticAction: true, }, }, }); @@ -2089,7 +2089,6 @@ function deleteReportComment( isReportArchived: boolean | undefined, isOriginalReportArchived: boolean | undefined, currentEmail: string, - visibleReportActionsDataParam?: VisibleReportActionsDerivedValue, ) { const originalReportID = getOriginalReportID(reportID, reportAction); const reportActionID = reportAction.reportActionID; @@ -2136,7 +2135,7 @@ function deleteReportComment( (action) => action.reportActionID !== reportAction.reportActionID && ReportActionsUtils.didMessageMentionCurrentUser(action, currentEmail) && - ReportActionsUtils.isReportActionVisible(action, reportID, undefined, visibleReportActionsDataParam), + ReportActionsUtils.shouldReportActionBeVisible(action, action.reportActionID), ); optimisticReport.lastMentionedTime = latestMentionedReportAction?.created ?? null; } @@ -2292,7 +2291,6 @@ function editReportComment( isOriginalParentReportArchived: boolean | undefined, currentUserLogin: string, videoAttributeCache?: Record, - visibleReportActionsDataParam?: VisibleReportActionsDerivedValue, ) { const originalReportID = originalReport?.reportID; if (!originalReportID || !originalReportAction) { @@ -2325,7 +2323,7 @@ function editReportComment( // Delete the comment if it's empty if (!htmlForNewComment) { - deleteReportComment(originalReportID, originalReportAction, ancestors, isOriginalReportArchived, isOriginalParentReportArchived, currentUserLogin, visibleReportActionsDataParam); + deleteReportComment(originalReportID, originalReportAction, ancestors, isOriginalReportArchived, isOriginalParentReportArchived, currentUserLogin); return; } diff --git a/src/libs/actions/Transaction.ts b/src/libs/actions/Transaction.ts index 1bee5386a33d..f8b49c7c995f 100644 --- a/src/libs/actions/Transaction.ts +++ b/src/libs/actions/Transaction.ts @@ -52,6 +52,7 @@ import type { } from '@src/types/onyx'; import type {OriginalMessageIOU, OriginalMessageModifiedExpense} from '@src/types/onyx/OriginalMessage'; import type {OnyxData} from '@src/types/onyx/Request'; +import type {SearchDataTypes} from '@src/types/onyx/SearchResults'; import type {Waypoint, WaypointCollection} from '@src/types/onyx/Transaction'; import type TransactionState from '@src/types/utils/TransactionStateType'; import {getPolicyTags} from './IOU/index'; @@ -1541,6 +1542,10 @@ function getDraftTransactions(draftTransactions?: OnyxCollection): return Object.values(draftTransactions ?? allTransactionDrafts ?? {}).filter((transaction): transaction is Transaction => !!transaction); } +function mergeTransactionIdsHighlightOnSearchRoute(type: SearchDataTypes, data: Record | null) { + return Onyx.merge(ONYXKEYS.TRANSACTION_IDS_HIGHLIGHT_ON_SEARCH_ROUTE, {[type]: data}); +} + function getDuplicateTransactionDetails(transactionID?: string) { if (!transactionID) { return; @@ -1571,5 +1576,6 @@ export { revert, changeTransactionsReport, setTransactionReport, + mergeTransactionIdsHighlightOnSearchRoute, getDuplicateTransactionDetails, }; diff --git a/src/libs/actions/TransactionEdit.ts b/src/libs/actions/TransactionEdit.ts index e0204436e42b..65b61bd76489 100644 --- a/src/libs/actions/TransactionEdit.ts +++ b/src/libs/actions/TransactionEdit.ts @@ -151,7 +151,7 @@ type BuildOptimisticTransactionParams = { function buildOptimisticTransactionAndCreateDraft({initialTransaction, currentUserPersonalDetails, reportID}: BuildOptimisticTransactionParams): Transaction { const newTransactionID = generateTransactionID(); - const {currency, iouRequestType, isFromGlobalCreate} = initialTransaction ?? {}; + const {currency, iouRequestType, isFromGlobalCreate, isFromFloatingActionButton} = initialTransaction ?? {}; const newTransaction = { amount: 0, created: format(new Date(), 'yyyy-MM-dd'), @@ -161,6 +161,7 @@ function buildOptimisticTransactionAndCreateDraft({initialTransaction, currentUs reportID, transactionID: newTransactionID, isFromGlobalCreate, + isFromFloatingActionButton, merchant: CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT, } as Transaction; createDraftTransaction(newTransaction); diff --git a/src/libs/actions/replaceOptimisticReportWithActualReport.ts b/src/libs/actions/replaceOptimisticReportWithActualReport.ts index f37e7932189f..3d8413f05f52 100644 --- a/src/libs/actions/replaceOptimisticReportWithActualReport.ts +++ b/src/libs/actions/replaceOptimisticReportWithActualReport.ts @@ -2,12 +2,13 @@ import {DeviceEventEmitter, InteractionManager} from 'react-native'; import type {OnyxCollection} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; import Navigation, {navigationRef} from '@libs/Navigation/Navigation'; -import {isMoneyRequest, isMoneyRequestReport} from '@libs/ReportUtils'; +import {isMoneyRequest, isMoneyRequestReport, isOneTransactionReport} from '@libs/ReportUtils'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Route} from '@src/ROUTES'; +import ROUTES from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; import type {Report, ReportActions} from '@src/types/onyx'; -import {saveReportDraftComment} from './Report'; +import {openReport, saveReportDraftComment} from './Report'; /** * replaceOptimisticReportWithActualReport @@ -62,36 +63,70 @@ function replaceOptimisticReportWithActualReport(report: Report, draftReportComm } // Handle cleanup of stale optimistic IOU report and its report preview separately - if ((isMoneyRequestReport(report) || isMoneyRequest(report)) && parentReportID && parentReportActionID) { + if (isMoneyRequestReport(report) && parentReportID && parentReportActionID) { + Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentReportID}`, { + [parentReportActionID]: null, + }); + Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, null); + return; + } + + // If an optimistic IOU action was created before we knew a preexisting IOU action for the thread existed, + // remove it to avoid duplicate IOU report actions + if (isMoneyRequest(report) && parentReportID && parentReportActionID) { const parentReportAction = allReportActions?.[parentReportID]?.[parentReportActionID]; - if (parentReportAction?.childReportID === reportID) { + if (parentReportAction?.isOptimisticAction) { Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentReportID}`, { [parentReportActionID]: null, }); } - Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, null); - return; } // eslint-disable-next-line @typescript-eslint/no-deprecated InteractionManager.runAfterInteractions(() => { // It is possible that we optimistically created a DM/group-DM for a set of users for which a report already exists. + // Or we optimistically created a transaction thread chat report for an IOU report action that already has an associated child chat report. + // Or we optimistically created a thread report under a comment that already has an associated child chat report. // In this case, the API will let us know by returning a preexistingReportID. // We should clear out the optimistically created report and re-route the user to the preexisting report. + const existingReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${preexistingReportID}`]; + const parentReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${parentReportID}`]; let callback = () => { - const existingReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${preexistingReportID}`]; - Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, null); - Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${preexistingReportID}`, { - ...report, - reportID: preexistingReportID, - preexistingReportID: null, - // Replacing the existing report's participants to avoid duplicates - participants: existingReport?.participants ?? report.participants, - }); Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${reportID}`, null); + + if (!parentReportActionID) { + // DMs and group-DMs don't have parent actions, so we only need to merge report data and preserve existing participants + Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${preexistingReportID}`, { + ...report, + reportID: preexistingReportID, + preexistingReportID: null, + // Replacing the existing report's participants to avoid duplicates + participants: existingReport?.participants ?? report.participants, + }); + } else { + // Thread reports have parent actions that need their childReportID updated to point to the preexisting thread + Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${preexistingReportID}`, { + ...report, + reportID: preexistingReportID, + preexistingReportID: null, + }); + // Non-optimistic parent actions already exist, so we update their childReportID; + // optimistic actions were already cleaned up above + const parentReportAction = parentReportID ? allReportActions?.[parentReportID]?.[parentReportActionID] : null; + if (parentReportAction && !parentReportAction.isOptimisticAction) { + Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentReportID}`, { + [parentReportActionID]: {childReportID: preexistingReportID}, + }); + } + } }; + const isParentOneTransactionReport = isOneTransactionReport(parentReport); + + // One-transaction reports display the expense via the parent IOU report screen, not the thread, so the draft belongs there + const reportToCopyDraftTo = !!parentReportID && isParentOneTransactionReport ? parentReportID : preexistingReportID; + if (!navigationRef.isReady()) { callback(); return; @@ -116,25 +151,61 @@ function replaceOptimisticReportWithActualReport(report: Report, draftReportComm if (isOptimisticReportFocused || isOptimisticReportInBackground) { const currCallback = callback; callback = () => { - currCallback(); if (isOptimisticReportFocused) { - Navigation.setParams({reportID: preexistingReportID.toString()}); + if (!parentReportActionID || !isParentOneTransactionReport) { + // We are either in a DM/group-DM that do not have a parent report, + // a thread under any comment, + // or transaction thread report under an IOU report action that its parent IOU report is not a one expense report, + // we need to navigate to the preexisting report chat + // because we will clear the optimistically created report in the currCallback + Navigation.setParams({reportID: preexistingReportID.toString()}); + } else { + // We are in a transaction thread report under an IOU report action where the parent IOU report is a one transaction report + // We need to navigate to the one expense report screen instead of the preexisting report chat + // because we will clear the optimistically created transaction thread report in the currCallback + // and the one transaction should be accessed via the one expense report screen and not the preexisting report chat + Navigation.setParams({reportID: parentReportID}); + } } else if (isOptimisticReportInBackground) { // Navigate to the correct backTo route with the preexisting report ID Navigation.navigate(backTo.replace(`/r/${reportID}`, `/r/${preexistingReportID}`) as Route); } + currCallback(); }; // The report screen will listen to this event and transfer the draft comment to the existing report // This will allow the newest draft comment to be transferred to the existing report DeviceEventEmitter.emit(`switchToPreExistingReport_${reportID}`, { preexistingReportID, + reportToCopyDraftTo, callback, }); return; } + if ( + parentReportID && + isParentOneTransactionReport && + (activeRoute.includes(ROUTES.REPORT_WITH_ID.getRoute(parentReportID)) || activeRoute.includes(ROUTES.SEARCH_REPORT.getRoute({reportID: parentReportID}))) + ) { + if (draftReportComment) { + // Draft must be saved first because the callback will clear the optimistic report and its associated draft + saveReportDraftComment(parentReportID, draftReportComment, () => { + callback(); + + // We are already on the parent one expense report, so just call the API to fetch report data + openReport(parentReportID); + }); + } else { + callback(); + + // We are already on the parent one expense report, so just call the API to fetch report data + openReport(parentReportID); + } + return; + } + // In case the user is not on the report screen, we will transfer the report draft comment directly to the existing report // after that clear the optimistically created report if (!draftReportComment) { @@ -142,7 +213,7 @@ function replaceOptimisticReportWithActualReport(report: Report, draftReportComm return; } - saveReportDraftComment(preexistingReportID, draftReportComment, callback); + saveReportDraftComment(reportToCopyDraftTo, draftReportComment, callback); }); } diff --git a/src/libs/registerPaginationConfig.ts b/src/libs/registerPaginationConfig.ts index 47940e8cb2c8..a05c1474a706 100644 --- a/src/libs/registerPaginationConfig.ts +++ b/src/libs/registerPaginationConfig.ts @@ -46,7 +46,7 @@ registerPaginationConfig({ const reportNameValuePairs = allReportNameValuePairs?.[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${reportID}`]; const isReportArchived = !!reportNameValuePairs?.private_isArchived; const canUserPerformWriteAction = canUserPerformWriteActionReportUtils(report, isReportArchived); - return getSortedReportActionsForDisplay(reportActions, canUserPerformWriteAction, true, undefined, reportID); + return getSortedReportActionsForDisplay(reportActions, canUserPerformWriteAction, true); }, getItemID: (reportAction) => reportAction.reportActionID, }); diff --git a/src/pages/Debug/Report/DebugReportActions.tsx b/src/pages/Debug/Report/DebugReportActions.tsx index c211a477a21f..d1662bdba411 100644 --- a/src/pages/Debug/Report/DebugReportActions.tsx +++ b/src/pages/Debug/Report/DebugReportActions.tsx @@ -45,9 +45,9 @@ function DebugReportActions({reportID}: DebugReportActionsProps) { const getSortedAllReportActionsSelector = useCallback( (allReportActions: OnyxEntry): ReportAction[] => { - return getSortedReportActionsForDisplay(allReportActions, ifUserCanPerformWriteAction, true, undefined, reportID); + return getSortedReportActionsForDisplay(allReportActions, ifUserCanPerformWriteAction, true); }, - [ifUserCanPerformWriteAction, reportID], + [ifUserCanPerformWriteAction], ); const [sortedAllReportActions] = useOnyx( diff --git a/src/pages/EditReportFieldPage.tsx b/src/pages/EditReportFieldPage.tsx index 01132a56b44a..824c9689acb4 100644 --- a/src/pages/EditReportFieldPage.tsx +++ b/src/pages/EditReportFieldPage.tsx @@ -1,13 +1,14 @@ import {Str} from 'expensify-common'; -import React, {useState} from 'react'; +import React from 'react'; import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; -import ConfirmModal from '@components/ConfirmModal'; import type {FormOnyxValues} from '@components/Form/types'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import * as Expensicons from '@components/Icon/Expensicons'; +import {ModalActions} from '@components/Modal/Global/ModalContext'; import {useSession} from '@components/OnyxListItemProvider'; import type {PopoverMenuItem} from '@components/PopoverMenu'; import ScreenWrapper from '@components/ScreenWrapper'; +import useConfirmModal from '@hooks/useConfirmModal'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; @@ -50,8 +51,8 @@ function EditReportFieldPage({route}: EditReportFieldPageProps) { const [transactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS, {canBeMissing: true}); const hasViolations = hasViolationsReportUtils(report?.reportID, transactionViolations, session?.accountID ?? CONST.DEFAULT_NUMBER_ID, session?.email ?? ''); - const [isDeleteModalVisible, setIsDeleteModalVisible] = useState(false); const {translate} = useLocalize(); + const {showConfirmModal} = useConfirmModal(); const isReportFieldTitle = isReportFieldOfTypeTitle(reportField); const reportFieldsEnabled = ((isPaidGroupPolicyExpenseReport(report) || isInvoiceReport(report)) && !!policy?.areReportFieldsEnabled) || isReportFieldTitle; const hasOtherViolations = @@ -77,12 +78,24 @@ function EditReportFieldPage({route}: EditReportFieldPageProps) { Navigation.goBack(backTo); }; - const handleReportFieldDelete = () => { - setIsDeleteModalVisible(false); - goBack(); - setTimeout(() => { - deleteReportField(report.reportID, reportField); - }, CONST.ANIMATED_TRANSITION); + const handleReportFieldDelete = async () => { + const result = await showConfirmModal({ + title: translate('workspace.reportFields.delete'), + prompt: translate('workspace.reportFields.deleteConfirmation'), + confirmText: translate('common.delete'), + cancelText: translate('common.cancel'), + danger: true, + shouldEnableNewFocusManagement: true, + }); + if (result.action !== ModalActions.CONFIRM) { + return; + } + Navigation.setNavigationActionToMicrotaskQueue(() => { + goBack(); + setTimeout(() => { + deleteReportField(report.reportID, reportField); + }, CONST.ANIMATED_TRANSITION); + }); }; const fieldValue = isReportFieldTitle ? (report.reportName ?? '') : (reportField.value ?? reportField.defaultValue); @@ -121,7 +134,14 @@ function EditReportFieldPage({route}: EditReportFieldPageProps) { const isReportFieldDeletable = reportField.deletable && reportField?.fieldID !== CONST.REPORT_FIELD_TITLE_FIELD_ID; if (isReportFieldDeletable) { - menuItems.push({icon: Expensicons.Trashcan, text: translate('common.delete'), onSelected: () => setIsDeleteModalVisible(true), shouldCallAfterModalHide: true}); + menuItems.push({ + icon: Expensicons.Trashcan, + text: translate('common.delete'), + onSelected: () => { + handleReportFieldDelete(); + }, + shouldCallAfterModalHide: true, + }); } const fieldName = Str.UCFirst(reportField.name); @@ -139,18 +159,6 @@ function EditReportFieldPage({route}: EditReportFieldPageProps) { onBackButtonPress={goBack} /> - setIsDeleteModalVisible(false)} - prompt={translate('workspace.reportFields.deleteConfirmation')} - confirmText={translate('common.delete')} - cancelText={translate('common.cancel')} - danger - shouldEnableNewFocusManagement - /> - {(reportField.type === CONST.REPORT_FIELD_TYPES.TEXT || isReportFieldTitle) && ( ({ + const {CurrentPage, isEditing, currentPageName, pageIndex, prevPage, nextPage, moveTo, isRedirecting} = useSubPage({ pages: formPages, startFrom, onFinished: handleFinishStep, buildRoute: (pageName, action) => ROUTES.MISSING_PERSONAL_DETAILS.getRoute(pageName, action), }); + if (isRedirecting) { + return ; + } + const handleBackButtonPress = () => { if (isEditing) { Navigation.goBack(ROUTES.MISSING_PERSONAL_DETAILS.getRoute(CONST.MISSING_PERSONAL_DETAILS.PAGE_NAME.CONFIRM)); diff --git a/src/pages/Search/AdvancedSearchFilters.tsx b/src/pages/Search/AdvancedSearchFilters.tsx index 8c77d9f28cb0..dc5220da98cc 100644 --- a/src/pages/Search/AdvancedSearchFilters.tsx +++ b/src/pages/Search/AdvancedSearchFilters.tsx @@ -309,12 +309,12 @@ function getFilterCardDisplayTitle(filters: Partial, } function getFilterParticipantDisplayTitle(accountIDs: string[], personalDetails: PersonalDetailsList | undefined, formatPhoneNumber: LocaleContextProps['formatPhoneNumber']) { - const selectedPersonalDetails = accountIDs.map((id) => personalDetails?.[id]); - - return selectedPersonalDetails - .map((personalDetail) => { + return accountIDs + .map((id) => { + const personalDetail = personalDetails?.[id]; if (!personalDetail) { - return ''; + // Name-only attendees are stored by displayName, not accountID + return id; } return createDisplayName(personalDetail.login ?? '', personalDetail, formatPhoneNumber); diff --git a/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersAttendeePage.tsx b/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersAttendeePage.tsx index d2f67a5d3c3b..e22dd30627ee 100644 --- a/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersAttendeePage.tsx +++ b/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersAttendeePage.tsx @@ -39,6 +39,7 @@ function SearchFiltersAttendeePage() { attendee: selectedAccountIDs, }); }} + shouldAllowNameOnlyOptions /> diff --git a/src/pages/Search/SearchColumnsPage.tsx b/src/pages/Search/SearchColumnsPage.tsx index 405aea75a7cb..ea46fb82c301 100644 --- a/src/pages/Search/SearchColumnsPage.tsx +++ b/src/pages/Search/SearchColumnsPage.tsx @@ -65,6 +65,9 @@ function SearchColumnsPage() { CONST.SEARCH.TABLE_COLUMNS.GROUP_MERCHANT, CONST.SEARCH.TABLE_COLUMNS.GROUP_TAG, CONST.SEARCH.TABLE_COLUMNS.GROUP_MONTH, + CONST.SEARCH.TABLE_COLUMNS.GROUP_WEEK, + CONST.SEARCH.TABLE_COLUMNS.GROUP_YEAR, + CONST.SEARCH.TABLE_COLUMNS.GROUP_QUARTER, ]); const sortColumns = (columnsToSort: ColumnItem[]): ColumnItem[] => { diff --git a/src/pages/Search/SearchPage.tsx b/src/pages/Search/SearchPage.tsx index 53450d6e4a83..42aec135c50e 100644 --- a/src/pages/Search/SearchPage.tsx +++ b/src/pages/Search/SearchPage.tsx @@ -1018,6 +1018,7 @@ function SearchPage({route}: SearchPageProps) { const saveFileAndInitMoneyRequest = (files: FileObject[]) => { const initialTransaction = initMoneyRequest({ isFromGlobalCreate: true, + isFromFloatingActionButton: true, reportID: newReportID, personalPolicy, newIouRequestType: CONST.IOU.REQUEST_TYPE.SCAN, diff --git a/src/pages/Share/ShareTab.tsx b/src/pages/Share/ShareTab.tsx index f454bd06c794..7d71ee819fdc 100644 --- a/src/pages/Share/ShareTab.tsx +++ b/src/pages/Share/ShareTab.tsx @@ -51,7 +51,6 @@ function ShareTab({ref}: ShareTabProps) { const [loginList] = useOnyx(ONYXKEYS.LOGIN_LIST, {canBeMissing: true}); const [draftComments] = useOnyx(ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT, {canBeMissing: true}); const [nvpDismissedProductTraining] = useOnyx(ONYXKEYS.NVP_DISMISSED_PRODUCT_TRAINING, {canBeMissing: true}); - const [visibleReportActionsData] = useOnyx(ONYXKEYS.DERIVED.VISIBLE_REPORT_ACTIONS, {canBeMissing: true}); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const currentUserAccountID = currentUserPersonalDetails.accountID; const currentUserEmail = currentUserPersonalDetails.email ?? ''; @@ -82,23 +81,10 @@ function ShareTab({ref}: ShareTabProps) { includeUserToInvite: true, countryCode, loginList, - visibleReportActionsData, currentUserAccountID, currentUserEmail, }); - }, [ - areOptionsInitialized, - options, - draftComments, - nvpDismissedProductTraining, - betas, - textInputValue, - countryCode, - loginList, - visibleReportActionsData, - currentUserAccountID, - currentUserEmail, - ]); + }, [areOptionsInitialized, options, draftComments, nvpDismissedProductTraining, betas, textInputValue, countryCode, loginList, currentUserAccountID, currentUserEmail]); const recentReportsOptions = useMemo(() => { if (textInputValue.trim() === '') { diff --git a/src/pages/Travel/TravelTerms.tsx b/src/pages/Travel/TravelTerms.tsx index bd8d2344dbdf..61c1360af31d 100644 --- a/src/pages/Travel/TravelTerms.tsx +++ b/src/pages/Travel/TravelTerms.tsx @@ -1,16 +1,17 @@ import type {StackScreenProps} from '@react-navigation/stack'; import Str from 'expensify-common/dist/str'; -import React, {useCallback, useEffect, useState} from 'react'; +import React, {useState} from 'react'; import {View} from 'react-native'; import {ScrollView} from 'react-native-gesture-handler'; import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; import CheckboxWithLabel from '@components/CheckboxWithLabel'; -import ConfirmModal from '@components/ConfirmModal'; import FormAlertWithSubmitButton from '@components/FormAlertWithSubmitButton'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import {ModalActions} from '@components/Modal/Global/ModalContext'; import RenderHTML from '@components/RenderHTML'; import ScreenWrapper from '@components/ScreenWrapper'; import Text from '@components/Text'; +import useConfirmModal from '@hooks/useConfirmModal'; import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; @@ -30,30 +31,30 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; +import type {TravelProvisioning} from '@src/types/onyx'; type TravelTermsPageProps = StackScreenProps; function TravelTerms({route}: TravelTermsPageProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); + const {showConfirmModal} = useConfirmModal(); const StyleUtils = useStyleUtils(); const illustrations = useMemoizedLazyIllustrations(['RocketDude']); const {isBetaEnabled} = usePermissions(); const isBlockedFromSpotnanaTravel = isBetaEnabled(CONST.BETAS.PREVENT_SPOTNANA_TRAVEL); const [hasAcceptedTravelTerms, setHasAcceptedTravelTerms] = useState(false); - const [errorMessage, setErrorMessage] = useState(''); - const [showVerifyCompanyModal, setShowVerifyCompanyModal] = useState(false); const [travelProvisioning] = useOnyx(ONYXKEYS.TRAVEL_PROVISIONING, {canBeMissing: true}); + const [account] = useOnyx(ONYXKEYS.ACCOUNT, {canBeMissing: true}); + const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID, {canBeMissing: true}); + const [conciergeReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${conciergeReportID}`, {canBeMissing: true}); + const errorMessage = travelProvisioning?.errors && !travelProvisioning?.error ? getLatestErrorMessage(travelProvisioning) : ''; const isLoading = travelProvisioning?.isLoading; const domain = route.params.domain === CONST.TRAVEL.DEFAULT_DOMAIN ? undefined : route.params.domain; const policyID = route.params.policyID; - const [account] = useOnyx(ONYXKEYS.ACCOUNT, {canBeMissing: true}); - const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID, {canBeMissing: true}); - const [conciergeReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${conciergeReportID}`, {canBeMissing: true}); - - const createTravelEnablementIssue = useCallback(() => { + const createTravelEnablementIssue = () => { if (!conciergeReportID) { return; } @@ -62,118 +63,100 @@ function TravelTerms({route}: TravelTermsPageProps) { addComment(conciergeReport, conciergeReportID, [], message, CONST.DEFAULT_TIME_ZONE); Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(conciergeReportID)); - }, [translate, account?.primaryLogin, conciergeReportID, conciergeReport]); - - useEffect(() => { - if (travelProvisioning?.error === CONST.TRAVEL.PROVISIONING.ERROR_PERMISSION_DENIED && domain) { - Navigation.navigate(ROUTES.TRAVEL_DOMAIN_PERMISSION_INFO.getRoute(domain)); - cleanupTravelProvisioningSession(); - } - - if (travelProvisioning?.error === CONST.TRAVEL.PROVISIONING.ERROR_ADDITIONAL_VERIFICATION_REQUIRED) { - setShowVerifyCompanyModal(true); - } - - if (travelProvisioning?.spotnanaToken) { - Navigation.closeRHPFlow(); - cleanupTravelProvisioningSession(); - } - if (travelProvisioning?.errors && !travelProvisioning?.error) { - setErrorMessage(getLatestErrorMessage(travelProvisioning)); - } - }, [travelProvisioning, domain]); - - const toggleTravelTerms = () => { - setHasAcceptedTravelTerms(!hasAcceptedTravelTerms); }; - useEffect(() => { - if (!hasAcceptedTravelTerms) { - return; - } - - setErrorMessage(''); - }, [hasAcceptedTravelTerms]); + const acceptTermsAndOpenTravelDot = () => { + asyncOpenURL( + acceptSpotnanaTerms(domain, policyID).then((response) => { + // Extract the error code from onyxData - the backend sets errors in TRAVEL_PROVISIONING via onyxData + const travelProvisioningData = response?.onyxData?.find((data) => data.key === ONYXKEYS.TRAVEL_PROVISIONING); + const errorCode = (travelProvisioningData?.value as Partial | undefined)?.error; + + // Handle permission denied error + if (errorCode === CONST.TRAVEL.PROVISIONING.ERROR_PERMISSION_DENIED && domain) { + Navigation.navigate(ROUTES.TRAVEL_DOMAIN_PERMISSION_INFO.getRoute(domain)); + cleanupTravelProvisioningSession(); + return Promise.reject(new Error('Permission denied')); + } + + // Handle verification required error - show modal and reject to close Safari window if open + if (errorCode === CONST.TRAVEL.PROVISIONING.ERROR_ADDITIONAL_VERIFICATION_REQUIRED) { + showConfirmModal({ + title: translate('travel.verifyCompany.title'), + titleStyles: styles.textHeadlineH1, + titleContainerStyles: styles.mb2, + prompt: translate('travel.verifyCompany.message'), + promptStyles: styles.mb2, + confirmText: translate('travel.verifyCompany.confirmText'), + shouldShowCancelButton: false, + image: illustrations.RocketDude, + imageStyles: StyleUtils.getBackgroundColorStyle(colors.ice600), + // Disable browser history handling since we handle navigation ourselves + shouldHandleNavigationBack: false, + }).then((result) => { + // Only navigate to concierge on confirm, on backdrop just close modal (TravelTerms stays visible) + if (result.action !== ModalActions.CONFIRM) { + return; + } + createTravelEnablementIssue(); + }); + return Promise.reject(new Error('Verification required')); + } + + // Handle general API failure + if (response?.jsonCode !== 200) { + return Promise.reject(new Error('Request failed')); + } + + // Handle success - build URL, cleanup, and return URL for asyncOpenURL to open + if (response?.spotnanaToken) { + const travelDotURL = buildTravelDotURL(response.spotnanaToken, response.isTestAccount ?? false); + Navigation.closeRHPFlow(); + cleanupTravelProvisioningSession(); + return travelDotURL; + } + + return Promise.reject(new Error('No token received')); + }), + (travelDotURL) => travelDotURL ?? '', + ); + }; - // Add beta support for FullPageNotFound that is universal across travel pages return ( - <> - - - Navigation.goBack()} - /> - - - {`${translate('travel.termsAndConditions.title')}`} - - - - + + + Navigation.goBack()} + /> + + + {`${translate('travel.termsAndConditions.title')}`} + + - - { - if (!hasAcceptedTravelTerms) { - setErrorMessage(translate('travel.termsAndConditions.error')); - return; - } - if (errorMessage) { - setErrorMessage(''); - } - - asyncOpenURL( - acceptSpotnanaTerms(domain, policyID).then((response) => { - if (response?.jsonCode !== 200) { - return Promise.reject(); - } - if (response?.spotnanaToken) { - return buildTravelDotURL(response.spotnanaToken, response.isTestAccount ?? false); - } - }), - (travelDotURL) => travelDotURL ?? '', - ); - }} - message={errorMessage} - isAlertVisible={!!errorMessage} - containerStyles={[styles.mh0, styles.mt5]} - isLoading={isLoading} + setHasAcceptedTravelTerms((prev) => !prev)} + label={translate('travel.termsAndConditions.label')} /> - - - - - { - createTravelEnablementIssue(); - setShowVerifyCompanyModal(false); - }} - onCancel={() => { - setShowVerifyCompanyModal(false); - }} - title={translate('travel.verifyCompany.title')} - titleStyles={styles.textHeadlineH1} - titleContainerStyles={styles.mb2} - prompt={translate('travel.verifyCompany.message')} - promptStyles={styles.mb2} - confirmText={translate('travel.verifyCompany.confirmText')} - shouldShowCancelButton={false} - image={illustrations.RocketDude} - imageStyles={StyleUtils.getBackgroundColorStyle(colors.ice600)} - /> - + + + + + ); } diff --git a/src/pages/inbox/ReportScreen.tsx b/src/pages/inbox/ReportScreen.tsx index d940950b1e13..40ccef6d93ca 100644 --- a/src/pages/inbox/ReportScreen.tsx +++ b/src/pages/inbox/ReportScreen.tsx @@ -59,10 +59,10 @@ import { isCreatedAction, isDeletedParentAction, isMoneyRequestAction, - isReportActionVisible, isSentMoneyReportAction, isTransactionThread, isWhisperAction, + shouldReportActionBeVisible, } from '@libs/ReportActionsUtils'; import { canEditReportAction, @@ -306,7 +306,6 @@ function ReportScreen({route, navigation, isInSidePanel = false}: ReportScreenPr const [currentUserAccountID = -1] = useOnyx(ONYXKEYS.SESSION, {selector: accountIDSelector, canBeMissing: false}); const [isLoadingApp] = useOnyx(ONYXKEYS.IS_LOADING_APP, {canBeMissing: true}); - const [visibleReportActionsData] = useOnyx(ONYXKEYS.DERIVED.VISIBLE_REPORT_ACTIONS, {canBeMissing: true}); const {reportActions: unfilteredReportActions, linkedAction, sortedAllReportActions, hasNewerActions, hasOlderActions} = usePaginatedReportActions(reportID, reportActionIDFromRoute); // wrapping in useMemo because this is array operation and can cause performance issues const reportActions = useMemo(() => getFilteredReportActionsForReportView(unfilteredReportActions), [unfilteredReportActions]); @@ -483,16 +482,10 @@ function ReportScreen({route, navigation, isInSidePanel = false}: ReportScreenPr const isReportArchived = useReportIsArchived(report?.reportID); const {isEditingDisabled, isCurrentReportLoadedFromOnyx} = useIsReportReadyToDisplay(report, reportIDFromRoute, isReportArchived); - const isLinkedActionDeleted = useMemo(() => { - if (!linkedAction) { - return false; - } - const actionReportID = linkedAction.reportID ?? reportID; - if (!actionReportID) { - return true; - } - return !isReportActionVisible(linkedAction, actionReportID, canUserPerformWriteAction(report, isReportArchived), visibleReportActionsData); - }, [linkedAction, report, isReportArchived, reportID, visibleReportActionsData]); + const isLinkedActionDeleted = useMemo( + () => !!linkedAction && !shouldReportActionBeVisible(linkedAction, linkedAction.reportActionID, canUserPerformWriteAction(report, isReportArchived)), + [linkedAction, report, isReportArchived], + ); const prevIsLinkedActionDeleted = usePrevious(linkedAction ? isLinkedActionDeleted : undefined); diff --git a/src/pages/inbox/report/ContextMenu/PopoverReportActionContextMenu.tsx b/src/pages/inbox/report/ContextMenu/PopoverReportActionContextMenu.tsx index 8bbd0b46bf17..a07efba6485e 100644 --- a/src/pages/inbox/report/ContextMenu/PopoverReportActionContextMenu.tsx +++ b/src/pages/inbox/report/ContextMenu/PopoverReportActionContextMenu.tsx @@ -86,7 +86,6 @@ function PopoverReportActionContextMenu({ref}: PopoverReportActionContextMenuPro const [shouldSwitchPositionIfOverflow, setShouldSwitchPositionIfOverflow] = useState(false); const [isWithoutOverlay, setIsWithoutOverlay] = useState(true); const [allTransactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS, {canBeMissing: true}); - const [visibleReportActionsData] = useOnyx(ONYXKEYS.DERIVED.VISIBLE_REPORT_ACTIONS, {canBeMissing: true}); const contentRef = useRef(null); const anchorRef = useRef(null); @@ -374,7 +373,7 @@ function PopoverReportActionContextMenu({ref}: PopoverReportActionContextMenuPro } else if (reportAction) { // eslint-disable-next-line @typescript-eslint/no-deprecated InteractionManager.runAfterInteractions(() => { - deleteReportComment(reportIDRef.current, reportAction, ancestorsRef.current, isReportArchived, isOriginalReportArchived, email ?? '', visibleReportActionsData ?? undefined); + deleteReportComment(reportIDRef.current, reportAction, ancestorsRef.current, isReportArchived, isOriginalReportArchived, email ?? ''); }); } @@ -395,7 +394,6 @@ function PopoverReportActionContextMenu({ref}: PopoverReportActionContextMenuPro isOriginalReportArchived, allTransactionViolations, bankAccountList, - visibleReportActionsData, currentUserAccountID, ]); diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx index 9c17ef812bda..c024fb93bc14 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx @@ -147,6 +147,7 @@ type ComposerWithSuggestionsProps = Partial & type SwitchToCurrentReportProps = { preexistingReportID: string; + reportToCopyDraftTo: string; callback: () => void; }; @@ -290,6 +291,14 @@ function ComposerWithSuggestions({ // The ref to check whether the comment saving is in progress const isCommentPendingSaved = useRef(false); + // Tracks transition state to prevent SilentCommentUpdater from overwriting the just-saved draft during report ID changes + const isTransitioningToPreExistingReport = useRef(false); + + // Callback to clear the transitioning flag - passed to SilentCommentUpdater to avoid prop mutation + const handleTransitionToPreExistingReportComplete = useCallback(() => { + isTransitioningToPreExistingReport.current = false; + }, []); + const animatedRef = useAnimatedRef(); /** * Set the TextInput Ref @@ -322,12 +331,16 @@ function ComposerWithSuggestions({ ); useEffect(() => { - const switchToCurrentReport = DeviceEventEmitter.addListener(`switchToPreExistingReport_${reportID}`, ({preexistingReportID, callback}: SwitchToCurrentReportProps) => { + const switchToCurrentReport = DeviceEventEmitter.addListener(`switchToPreExistingReport_${reportID}`, ({reportToCopyDraftTo, callback}: SwitchToCurrentReportProps) => { if (!commentRef.current) { callback(); return; } - saveReportDraftComment(preexistingReportID, commentRef.current, callback); + + // Mark that we're transitioning to a preexisting report + // This prevents SilentCommentUpdater from overwriting the draft + isTransitioningToPreExistingReport.current = true; + saveReportDraftComment(reportToCopyDraftTo, commentRef.current, callback); }); return () => { @@ -912,6 +925,8 @@ function ComposerWithSuggestions({ updateComment={updateComment} commentRef={commentRef} isCommentPendingSaved={isCommentPendingSaved} + isTransitioningToPreExistingReport={isTransitioningToPreExistingReport} + onTransitionToPreExistingReportComplete={handleTransitionToPreExistingReportComplete} /> )} diff --git a/src/pages/inbox/report/ReportActionCompose/SilentCommentUpdater/index.tsx b/src/pages/inbox/report/ReportActionCompose/SilentCommentUpdater/index.tsx index 4d403831ab2b..bad70057d33d 100644 --- a/src/pages/inbox/report/ReportActionCompose/SilentCommentUpdater/index.tsx +++ b/src/pages/inbox/report/ReportActionCompose/SilentCommentUpdater/index.tsx @@ -11,7 +11,15 @@ import type SilentCommentUpdaterProps from './types'; * It is connected to the actual draft comment in onyx. The comment in onyx might updates multiple times, and we want to avoid * re-rendering a UI component for that. That's why the side effect was moved down to a separate component. */ -function SilentCommentUpdater({commentRef, reportID, value, updateComment, isCommentPendingSaved}: SilentCommentUpdaterProps) { +function SilentCommentUpdater({ + commentRef, + reportID, + value, + updateComment, + isCommentPendingSaved, + isTransitioningToPreExistingReport, + onTransitionToPreExistingReportComplete, +}: SilentCommentUpdaterProps) { const [comment = '', commentResult] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${reportID}`, {canBeMissing: true}); const prevCommentProp = usePrevious(comment); const prevReportId = usePrevious(reportID); @@ -22,6 +30,14 @@ function SilentCommentUpdater({commentRef, reportID, value, updateComment, isCom if (isLoadingOnyxValue(commentResult)) { return; } + + // Skip sync when transitioning to a preexisting report + // This prevents overwriting the just-saved draft with an empty string + if (isTransitioningToPreExistingReport.current && reportID !== prevReportId) { + onTransitionToPreExistingReportComplete(); + return; + } + // Value state does not have the same value as comment props when the comment gets changed from another tab. // In this case, we should synchronize the value between tabs. const shouldSyncComment = prevCommentProp !== comment && value !== comment && !isCommentPendingSaved.current; @@ -33,7 +49,21 @@ function SilentCommentUpdater({commentRef, reportID, value, updateComment, isCom } updateComment(comment ?? ''); - }, [prevCommentProp, prevPreferredLocale, prevReportId, comment, preferredLocale, reportID, updateComment, value, commentRef, isCommentPendingSaved, commentResult]); + }, [ + prevCommentProp, + prevPreferredLocale, + prevReportId, + comment, + preferredLocale, + reportID, + updateComment, + value, + commentRef, + isCommentPendingSaved, + isTransitioningToPreExistingReport, + onTransitionToPreExistingReportComplete, + commentResult, + ]); return null; } diff --git a/src/pages/inbox/report/ReportActionCompose/SilentCommentUpdater/types.ts b/src/pages/inbox/report/ReportActionCompose/SilentCommentUpdater/types.ts index 2768dfc1250a..c11d2e73dc8b 100644 --- a/src/pages/inbox/report/ReportActionCompose/SilentCommentUpdater/types.ts +++ b/src/pages/inbox/report/ReportActionCompose/SilentCommentUpdater/types.ts @@ -13,6 +13,12 @@ type SilentCommentUpdaterProps = { /** The ref to check whether the comment saving is in progress */ isCommentPendingSaved: React.RefObject; + + /** The ref to check whether we're transitioning to a preexisting report */ + isTransitioningToPreExistingReport: React.RefObject; + + /** Callback to clear the transitioning flag after transition to preexisting report is complete */ + onTransitionToPreExistingReportComplete: () => void; }; export default SilentCommentUpdaterProps; diff --git a/src/pages/inbox/report/ReportActionItemMessageEdit.tsx b/src/pages/inbox/report/ReportActionItemMessageEdit.tsx index 7a600cce5a0f..5c624142612f 100644 --- a/src/pages/inbox/report/ReportActionItemMessageEdit.tsx +++ b/src/pages/inbox/report/ReportActionItemMessageEdit.tsx @@ -151,7 +151,6 @@ function ReportActionItemMessageEdit({ // The ref to check whether the comment saving is in progress const isCommentPendingSaved = useRef(false); const [originalReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${originalReportID}`, {canBeMissing: true}); - const [visibleReportActionsData] = useOnyx(ONYXKEYS.DERIVED.VISIBLE_REPORT_ACTIONS, {canBeMissing: true}); const isOriginalReportArchived = useReportIsArchived(originalReportID); const originalParentReportID = getOriginalReportID(originalReportID, action); const isOriginalParentReportArchived = useReportIsArchived(originalParentReportID); @@ -326,7 +325,6 @@ function ReportActionItemMessageEdit({ isOriginalParentReportArchived, email ?? '', Object.fromEntries(draftMessageVideoAttributeCache), - visibleReportActionsData ?? undefined, ); deleteDraft(); }, [ @@ -341,7 +339,6 @@ function ReportActionItemMessageEdit({ isOriginalParentReportArchived, debouncedValidateCommentMaxLength, email, - visibleReportActionsData, ]); /** diff --git a/src/pages/inbox/report/ReportActionsView.tsx b/src/pages/inbox/report/ReportActionsView.tsx index e78b800563ed..a1e2c00310d7 100755 --- a/src/pages/inbox/report/ReportActionsView.tsx +++ b/src/pages/inbox/report/ReportActionsView.tsx @@ -29,7 +29,7 @@ import { isDeletedParentAction, isIOUActionMatchingTransactionList, isMoneyRequestAction, - isReportActionVisible, + shouldReportActionBeVisible, } from '@libs/ReportActionsUtils'; import {buildOptimisticCreatedReportAction, buildOptimisticIOUReportAction, canUserPerformWriteAction, isInvoiceReport, isMoneyRequestReport} from '@libs/ReportUtils'; import markOpenReportEnd from '@libs/telemetry/markOpenReportEnd'; @@ -87,9 +87,9 @@ function ReportActionsView({ const getTransactionThreadReportActions = useCallback( (reportActions: OnyxEntry): OnyxTypes.ReportAction[] => { - return getSortedReportActionsForDisplay(reportActions, canPerformWriteAction, true, undefined, transactionThreadReportID ?? undefined); + return getSortedReportActionsForDisplay(reportActions, canPerformWriteAction, true); }, - [canPerformWriteAction, transactionThreadReportID], + [canPerformWriteAction], ); const [transactionThreadReportActions] = useOnyx( @@ -102,7 +102,6 @@ function ReportActionsView({ ); const [transactionThreadReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`, {canBeMissing: true}); const [isLoadingApp] = useOnyx(ONYXKEYS.IS_LOADING_APP, {canBeMissing: true}); - const [visibleReportActionsData] = useOnyx(ONYXKEYS.DERIVED.VISIBLE_REPORT_ACTIONS, {canBeMissing: true}); const prevTransactionThreadReport = usePrevious(transactionThreadReport); const reportActionID = route?.params?.reportActionID; const prevReportActionID = usePrevious(reportActionID); @@ -219,26 +218,13 @@ function ReportActionsView({ const visibleReportActions = useMemo( () => - reportActions.filter((reportAction) => { - const passesOfflineCheck = - isOffline || isDeletedParentAction(reportAction) || reportAction.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || reportAction.errors; - - if (!passesOfflineCheck) { - return false; - } - - const actionReportID = reportAction.reportID ?? reportID; - if (!isReportActionVisible(reportAction, actionReportID, canPerformWriteAction, visibleReportActionsData)) { - return false; - } - - if (!isIOUActionMatchingTransactionList(reportAction, reportTransactionIDs)) { - return false; - } - - return true; - }), - [reportActions, isOffline, canPerformWriteAction, reportTransactionIDs, visibleReportActionsData, reportID], + reportActions.filter( + (reportAction) => + (isOffline || isDeletedParentAction(reportAction) || reportAction.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || reportAction.errors) && + shouldReportActionBeVisible(reportAction, reportAction.reportActionID, canPerformWriteAction) && + isIOUActionMatchingTransactionList(reportAction, reportTransactionIDs), + ), + [reportActions, isOffline, canPerformWriteAction, reportTransactionIDs], ); const newestReportAction = useMemo(() => reportActions?.at(0), [reportActions]); @@ -323,8 +309,7 @@ function ReportActionsView({ return ; } - const hasDerivedValueTimingIssue = reportActions.length > 0 && isMissingReportActions; - if (hasDerivedValueTimingIssue || (!isReportTransactionThread && isMissingReportActions)) { + if (!isReportTransactionThread && isMissingReportActions) { return ; } diff --git a/src/pages/inbox/sidebar/FloatingActionButtonAndPopover.tsx b/src/pages/inbox/sidebar/FloatingActionButtonAndPopover.tsx index 41c12be98165..c7813aa387d2 100644 --- a/src/pages/inbox/sidebar/FloatingActionButtonAndPopover.tsx +++ b/src/pages/inbox/sidebar/FloatingActionButtonAndPopover.tsx @@ -343,7 +343,7 @@ function FloatingActionButtonAndPopover({onHideCreateMenu, onShowCreateMenu, ref } // Start the scan flow directly - startMoneyRequest(CONST.IOU.TYPE.CREATE, reportID, CONST.IOU.REQUEST_TYPE.SCAN, false, undefined, allTransactionDrafts); + startMoneyRequest(CONST.IOU.TYPE.CREATE, reportID, CONST.IOU.REQUEST_TYPE.SCAN, false, undefined, allTransactionDrafts, true); }); }, [shouldRedirectToExpensifyClassic, allTransactionDrafts, reportID, showRedirectToExpensifyClassicModal]); @@ -356,7 +356,7 @@ function FloatingActionButtonAndPopover({onHideCreateMenu, onShowCreateMenu, ref const quickActionReportID = policyChatForActivePolicy?.reportID ?? reportID; Tab.setSelectedTab(CONST.TAB.IOU_REQUEST_TYPE, CONST.IOU.REQUEST_TYPE.SCAN); - startMoneyRequest(CONST.IOU.TYPE.CREATE, quickActionReportID, CONST.IOU.REQUEST_TYPE.SCAN, !!policyChatForActivePolicy?.reportID, undefined, allTransactionDrafts); + startMoneyRequest(CONST.IOU.TYPE.CREATE, quickActionReportID, CONST.IOU.REQUEST_TYPE.SCAN, !!policyChatForActivePolicy?.reportID, undefined, allTransactionDrafts, true); }); }, [policyChatForActivePolicy?.policyID, policyChatForActivePolicy?.reportID, reportID, allTransactionDrafts]); @@ -439,7 +439,7 @@ function FloatingActionButtonAndPopover({onHideCreateMenu, onShowCreateMenu, ref showRedirectToExpensifyClassicModal(); return; } - startMoneyRequest(CONST.IOU.TYPE.CREATE, reportID, undefined, undefined, undefined, allTransactionDrafts); + startMoneyRequest(CONST.IOU.TYPE.CREATE, reportID, undefined, undefined, undefined, allTransactionDrafts, true); }), sentryLabel: CONST.SENTRY_LABEL.FAB_MENU.CREATE_EXPENSE, }, @@ -482,6 +482,7 @@ function FloatingActionButtonAndPopover({onHideCreateMenu, onShowCreateMenu, ref lastDistanceExpenseType, targetAccountPersonalDetails, currentUserAccountID: currentUserPersonalDetails.accountID, + isFromFloatingActionButton: true, }); }); }; @@ -508,7 +509,7 @@ function FloatingActionButtonAndPopover({onHideCreateMenu, onShowCreateMenu, ref } const quickActionReportID = policyChatForActivePolicy?.reportID || reportID; - startMoneyRequest(CONST.IOU.TYPE.SUBMIT, quickActionReportID, CONST.IOU.REQUEST_TYPE.SCAN, true, undefined, allTransactionDrafts); + startMoneyRequest(CONST.IOU.TYPE.SUBMIT, quickActionReportID, CONST.IOU.REQUEST_TYPE.SCAN, true, undefined, allTransactionDrafts, true); }); }; @@ -585,7 +586,7 @@ function FloatingActionButtonAndPopover({onHideCreateMenu, onShowCreateMenu, ref return; } // Start the flow to start tracking a distance request - startDistanceRequest(CONST.IOU.TYPE.CREATE, reportID, lastDistanceExpenseType); + startDistanceRequest(CONST.IOU.TYPE.CREATE, reportID, lastDistanceExpenseType, undefined, undefined, true); }); }, sentryLabel: CONST.SENTRY_LABEL.FAB_MENU.TRACK_DISTANCE, @@ -648,7 +649,7 @@ function FloatingActionButtonAndPopover({onHideCreateMenu, onShowCreateMenu, ref return; } - startMoneyRequest(CONST.IOU.TYPE.INVOICE, reportID, undefined, undefined, undefined, allTransactionDrafts); + startMoneyRequest(CONST.IOU.TYPE.INVOICE, reportID, undefined, undefined, undefined, allTransactionDrafts, true); }), sentryLabel: CONST.SENTRY_LABEL.FAB_MENU.SEND_INVOICE, }, @@ -701,6 +702,7 @@ function FloatingActionButtonAndPopover({onHideCreateMenu, onShowCreateMenu, ref {FabCreateReportConfirmationModal} ({ ...CONST.EXPENSIFY_EMAILS_OBJECT, - [currentVacationDelegate ?? '']: true, + [currentVacationDelegate]: true, }), [currentVacationDelegate], ); @@ -56,22 +55,13 @@ function VacationDelegatePage() { }, }); - const headerMessage = useMemo(() => { - return getHeaderMessage( - (availableOptions.recentReports?.length || 0) + (availableOptions.personalDetails?.length || 0) !== 0, - !!availableOptions.userToInvite, - debouncedSearchTerm.trim(), - countryCode, - false, - ); - }, [availableOptions.recentReports?.length, availableOptions.personalDetails?.length, availableOptions.userToInvite, debouncedSearchTerm, countryCode]); - const sections = useMemo(() => { const sectionsList = []; if (vacationDelegate && delegatePersonalDetails) { sectionsList.push({ title: undefined, + sectionIndex: 0, data: [ { ...delegatePersonalDetails, @@ -92,27 +82,26 @@ function VacationDelegatePage() { ], }, ], - shouldShow: true, }); } sectionsList.push({ title: translate('common.recents'), + sectionIndex: 1, data: availableOptions.recentReports, - shouldShow: availableOptions.recentReports?.length > 0, }); sectionsList.push({ title: translate('common.contacts'), + sectionIndex: 2, data: availableOptions.personalDetails, - shouldShow: availableOptions.personalDetails?.length > 0, }); if (availableOptions.userToInvite) { sectionsList.push({ title: undefined, + sectionIndex: 3, data: [availableOptions.userToInvite], - shouldShow: true, }); } @@ -164,6 +153,19 @@ function VacationDelegatePage() { searchInServer(debouncedSearchTerm); }, [debouncedSearchTerm]); + const textInputOptions = { + value: searchTerm, + onChangeText: setSearchTerm, + label: translate('selectionList.nameEmailOrPhoneNumber'), + headerMessage: getHeaderMessage( + (availableOptions.recentReports?.length || 0) + (availableOptions.personalDetails?.length || 0) !== 0, + !!availableOptions.userToInvite, + debouncedSearchTerm.trim(), + countryCode, + false, + ), + }; + return ( <> diff --git a/src/pages/workspace/WorkspaceMoreFeaturesPage.tsx b/src/pages/workspace/WorkspaceMoreFeaturesPage.tsx index 2376e6f94a6b..2cbe03a42ee3 100644 --- a/src/pages/workspace/WorkspaceMoreFeaturesPage.tsx +++ b/src/pages/workspace/WorkspaceMoreFeaturesPage.tsx @@ -317,7 +317,7 @@ function WorkspaceMoreFeaturesPage({policy, route}: WorkspaceMoreFeaturesPagePro Navigation.navigate(ROUTES.WORKSPACE_UPGRADE.getRoute(policyID, CONST.UPGRADE_FEATURE_INTRO_MAPPING.rules.alias, ROUTES.WORKSPACE_MORE_FEATURES.getRoute(policyID))); return; } - enablePolicyRules(policyID, isEnabled); + enablePolicyRules(policyID, isEnabled, undefined, policyData); }, onPress: () => { if (!policyID) { diff --git a/src/pages/workspace/rules/RulesAutoApproveReportsUnderPage.tsx b/src/pages/workspace/rules/RulesAutoApproveReportsUnderPage.tsx index f92f8110e925..63c628cf44a2 100644 --- a/src/pages/workspace/rules/RulesAutoApproveReportsUnderPage.tsx +++ b/src/pages/workspace/rules/RulesAutoApproveReportsUnderPage.tsx @@ -14,7 +14,7 @@ import {convertToFrontendAmountAsString} from '@libs/CurrencyUtils'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; -import {getWorkflowApprovalsUnavailable} from '@libs/PolicyUtils'; +import {getWorkflowApprovalsUnavailable, isPolicyFeatureEnabled} from '@libs/PolicyUtils'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; import {setPolicyAutomaticApprovalLimit} from '@userActions/Policy/Policy'; import CONST from '@src/CONST'; @@ -33,6 +33,7 @@ function RulesAutoApproveReportsUnderPage({route}: RulesAutoApproveReportsUnderP const styles = useThemeStyles(); const workflowApprovalsUnavailable = getWorkflowApprovalsUnavailable(policy); + const isWorkflowsEnabled = isPolicyFeatureEnabled(policy, CONST.POLICY.MORE_FEATURES.ARE_WORKFLOWS_ENABLED); const defaultValue = convertToFrontendAmountAsString(policy?.autoApproval?.limit ?? CONST.POLICY.AUTO_APPROVE_REPORTS_UNDER_DEFAULT_CENTS, policy?.outputCurrency); return ( @@ -40,7 +41,7 @@ function RulesAutoApproveReportsUnderPage({route}: RulesAutoApproveReportsUnderP policyID={route.params.policyID} accessVariants={[CONST.POLICY.ACCESS_VARIANTS.ADMIN, CONST.POLICY.ACCESS_VARIANTS.PAID]} featureName={CONST.POLICY.MORE_FEATURES.ARE_WORKFLOWS_ENABLED} - shouldBeBlocked={!policy?.shouldShowAutoApprovalOptions || workflowApprovalsUnavailable} + shouldBeBlocked={isWorkflowsEnabled && (!policy?.shouldShowAutoApprovalOptions || workflowApprovalsUnavailable)} > ) => { @@ -52,7 +54,7 @@ function RulesAutoPayReportsUnderPage({route}: RulesAutoPayReportsUnderPageProps policyID={policyID} accessVariants={[CONST.POLICY.ACCESS_VARIANTS.ADMIN, CONST.POLICY.ACCESS_VARIANTS.PAID]} featureName={CONST.POLICY.MORE_FEATURES.ARE_WORKFLOWS_ENABLED} - shouldBeBlocked={!policy?.shouldShowAutoReimbursementLimitOption || autoPayApprovedReportsUnavailable} + shouldBeBlocked={isWorkflowsEnabled && (!policy?.shouldShowAutoReimbursementLimitOption || autoPayApprovedReportsUnavailable)} > canModifyPlan(ownerPolicies, policy), [ownerPolicies, policy]); const isUpgraded = useMemo(() => isControlPolicy(policy), [policy]); + const policyData = usePolicyData(policyID); const perDiemCustomUnit = getPerDiemCustomUnit(policy); const categoryId = route.params?.categoryId; @@ -174,7 +176,7 @@ function WorkspaceUpgradePage({route}: WorkspaceUpgradePageProps) { } break; case CONST.UPGRADE_FEATURE_INTRO_MAPPING.rules.id: - enablePolicyRules(policyID, true, false); + enablePolicyRules(policyID, true, false, policyData); break; case CONST.UPGRADE_FEATURE_INTRO_MAPPING.companyCards.id: enableCompanyCards(policyID, true, false); diff --git a/src/pages/workspace/workflows/WorkspaceWorkflowsPayerPage.tsx b/src/pages/workspace/workflows/WorkspaceWorkflowsPayerPage.tsx index 16353e50f586..ca67fd851707 100644 --- a/src/pages/workspace/workflows/WorkspaceWorkflowsPayerPage.tsx +++ b/src/pages/workspace/workflows/WorkspaceWorkflowsPayerPage.tsx @@ -141,7 +141,7 @@ function WorkspaceWorkflowsPayerPage({route, policy, personalDetails, isLoadingR }); sectionsArray.push({ - title: formattedPolicyAdmins.length > 0 ? translate('workflowsPayerPage.admins') : undefined, + title: translate('workflowsPayerPage.admins'), data: formattedPolicyAdmins, sectionIndex: 2, }); diff --git a/src/styles/utils/index.ts b/src/styles/utils/index.ts index 7f1550ba6308..2ad579618885 100644 --- a/src/styles/utils/index.ts +++ b/src/styles/utils/index.ts @@ -1769,6 +1769,7 @@ const createStyleUtils = (theme: ThemeColors, styles: ThemeStyles) => ({ case CONST.SEARCH.TABLE_COLUMNS.GROUP_YEAR: case CONST.SEARCH.TABLE_COLUMNS.GROUP_QUARTER: case CONST.SEARCH.TABLE_COLUMNS.TAG: + case CONST.SEARCH.TABLE_COLUMNS.GROUP_TAG: columnWidth = {...getWidthStyle(variables.w36), ...styles.flex1}; break; case CONST.SEARCH.TABLE_COLUMNS.TAX_AMOUNT: diff --git a/src/types/onyx/DerivedValues.ts b/src/types/onyx/DerivedValues.ts index 7dfd3b59b897..0dc59c2f4dc5 100644 --- a/src/types/onyx/DerivedValues.ts +++ b/src/types/onyx/DerivedValues.ts @@ -73,11 +73,6 @@ type ReportTransactionsAndViolationsDerivedValue = Record>; -/** - * The derived value for visible report actions. - */ -type VisibleReportActionsDerivedValue = Record>; - /** * The errors of a card. */ @@ -221,7 +216,6 @@ export type { ReportTransactionsAndViolationsDerivedValue, ReportTransactionsAndViolations, OutstandingReportsByPolicyIDDerivedValue, - VisibleReportActionsDerivedValue, NonPersonalAndWorkspaceCardListDerivedValue, CardFeedErrorsDerivedValue, AllCardFeedErrorsMap, diff --git a/src/types/onyx/IOU.ts b/src/types/onyx/IOU.ts index 5858e25da3b6..d10145463b10 100644 --- a/src/types/onyx/IOU.ts +++ b/src/types/onyx/IOU.ts @@ -159,6 +159,9 @@ type SplitExpense = { /** Whether the split expense is reimbursable (out-of-pocket) or non-reimbursable (company spend) */ reimbursable?: boolean; + + /** Whether this split has been manually edited by the user (locks the value from auto-adjustment) */ + isManuallyEdited?: boolean; }; /** Model of IOU request */ diff --git a/src/types/onyx/Transaction.ts b/src/types/onyx/Transaction.ts index 4e342f66a3b8..2d150cf9bc7e 100644 --- a/src/types/onyx/Transaction.ts +++ b/src/types/onyx/Transaction.ts @@ -532,6 +532,9 @@ type Transaction = OnyxCommon.OnyxValueWithOfflineFeedback< /** Whether the transaction was created globally */ isFromGlobalCreate?: boolean; + /** Whether the transaction was created from the FAB, including Global create button, FloatingCameraButton, QuickAction,... */ + isFromFloatingActionButton?: boolean; + /** The transaction tax rate */ taxRate?: string | undefined; diff --git a/src/types/onyx/index.ts b/src/types/onyx/index.ts index 9401d36d54b7..c5f2a6916fc6 100644 --- a/src/types/onyx/index.ts +++ b/src/types/onyx/index.ts @@ -46,7 +46,6 @@ import type { OutstandingReportsByPolicyIDDerivedValue, ReportAttributesDerivedValue, ReportTransactionsAndViolationsDerivedValue, - VisibleReportActionsDerivedValue, } from './DerivedValues'; import type DismissedProductTraining from './DismissedProductTraining'; import type DismissedReferralBanners from './DismissedReferralBanners'; @@ -331,7 +330,6 @@ export type { LastSearchParams, ReportTransactionsAndViolationsDerivedValue, OutstandingReportsByPolicyIDDerivedValue, - VisibleReportActionsDerivedValue, NonPersonalAndWorkspaceCardListDerivedValue, CardFeedErrorsDerivedValue, ScheduleCallDraft, diff --git a/tests/actions/IOUTest.ts b/tests/actions/IOUTest.ts index bfdc71c07ee3..6b38eaf55aa7 100644 --- a/tests/actions/IOUTest.ts +++ b/tests/actions/IOUTest.ts @@ -27,6 +27,7 @@ import { getPerDiemExpenseInformation, getReportOriginalCreationTimestamp, getReportPreviewAction, + handleNavigateAfterExpenseCreate, initMoneyRequest, initSplitExpense, markRejectViolationAsResolved, @@ -44,8 +45,14 @@ import { updateMoneyRequestAmountAndCurrency, updateMoneyRequestAttendees, updateMoneyRequestCategory, + updateMoneyRequestDate, + updateMoneyRequestDescription, updateMoneyRequestDistance, + updateMoneyRequestDistanceRate, + updateMoneyRequestMerchant, updateMoneyRequestTag, + updateMoneyRequestTaxAmount, + updateMoneyRequestTaxRate, updateSplitExpenseAmountField, } from '@libs/actions/IOU'; import {putOnHold} from '@libs/actions/IOU/Hold'; @@ -58,6 +65,7 @@ import {clearAllRelatedReportActionErrors} from '@libs/actions/ReportActions'; import {subscribeToUserEvents} from '@libs/actions/User'; import type {ApiCommand} from '@libs/API/types'; import {WRITE_COMMANDS} from '@libs/API/types'; +import isReportTopmostSplitNavigator from '@libs/Navigation/helpers/isReportTopmostSplitNavigator'; import Navigation from '@libs/Navigation/Navigation'; import {rand64} from '@libs/NumberUtils'; import {getLoginsByAccountIDs} from '@libs/PersonalDetailsUtils'; @@ -115,6 +123,7 @@ jest.mock('@src/libs/Navigation/Navigation', () => ({ dismissModalWithReport: jest.fn(), goBack: jest.fn(), getTopmostReportId: jest.fn(() => topMostReportID), + getSearchTopmostReportId: jest.fn(() => topMostReportID), setNavigationActionToMicrotaskQueue: jest.fn(), removeScreenByKey: jest.fn(), isNavigationReady: jest.fn(() => Promise.resolve()), @@ -138,6 +147,7 @@ jest.mock('@src/libs/actions/Report', () => { }; }); jest.mock('@libs/Navigation/helpers/isSearchTopmostFullScreenRoute', () => jest.fn()); +jest.mock('@libs/Navigation/helpers/isReportTopmostSplitNavigator', () => jest.fn()); const unapprovedCashHash = 71801560; const unapprovedCashSimilarSearchHash = 1832274510; @@ -8899,6 +8909,35 @@ describe('actions/IOU', () => { }); }); }); + + it('should call notifyNewAction after updating the money request category', async () => { + const transactionID = '100'; + const transactionThreadReport = {reportID: '3'} as Report; + const fakePolicy: Policy = { + ...createRandomPolicy(22), + taxRates: CONST.DEFAULT_TAX, + rules: {}, + }; + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, {amount: 100, transactionID}); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}3`, transactionThreadReport); + + updateMoneyRequestCategory({ + transactionID, + transactionThreadReport, + parentReport: undefined, + category: 'Advertising', + policy: fakePolicy, + policyTagList: undefined, + policyCategories: undefined, + policyRecentlyUsedCategories: [], + currentUserAccountIDParam: 123, + currentUserEmailParam: 'existing@example.com', + isASAPSubmitBetaEnabled: false, + parentReportNextStep: undefined, + }); + + expect(notifyNewAction).toHaveBeenCalledTimes(1); + }); }); describe('setDraftSplitTransaction', () => { @@ -9915,6 +9954,56 @@ describe('actions/IOU', () => { }); expect(updatedTransaction?.modifiedAmount).toBe(''); }); + + it('should call notifyNewAction after updating the money request amount and currency', async () => { + const fakeReport: Report = { + ...createRandomReport(100, undefined), + type: CONST.REPORT.TYPE.EXPENSE, + policyID: '1', + stateNum: CONST.REPORT.STATE_NUM.APPROVED, + statusNum: CONST.REPORT.STATUS_NUM.APPROVED, + managerID: RORY_ACCOUNT_ID, + }; + const fakeTransaction: Transaction = { + ...createRandomTransaction(100), + reportID: fakeReport.reportID, + amount: 100, + currency: CONST.CURRENCY.USD, + }; + + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${fakeTransaction.transactionID}`, fakeTransaction); + + updateMoneyRequestAmountAndCurrency({ + transactionID: fakeTransaction.transactionID, + transactionThreadReport: fakeReport, + parentReport: undefined, + amount: 20000, + currency: CONST.CURRENCY.USD, + taxAmount: 0, + taxCode: '', + taxValue: '', + policy: { + id: '123', + role: CONST.POLICY.ROLE.USER, + type: CONST.POLICY.TYPE.TEAM, + name: '', + owner: '', + outputCurrency: '', + isPolicyExpenseChatEnabled: false, + }, + policyTagList: {}, + policyCategories: {}, + transactions: {}, + transactionViolations: {}, + currentUserAccountIDParam: 123, + currentUserEmailParam: 'existing@example.com', + isASAPSubmitBetaEnabled: false, + parentReportNextStep: undefined, + policyRecentlyUsedCurrencies: [], + }); + + expect(notifyNewAction).toHaveBeenCalledTimes(1); + }); }); describe('updateMoneyRequestAmountAndCurrency', () => { @@ -10368,6 +10457,7 @@ describe('actions/IOU', () => { category: 'Food', tags: ['lunch'], created: DateUtils.getDBTime(), + isManuallyEdited: true, // Lock the existing split so new split gets remaining amount }, ], attendees: [], @@ -10387,7 +10477,7 @@ describe('actions/IOU', () => { const splitExpenses = updatedDraftTransaction?.comment?.splitExpenses; expect(splitExpenses).toHaveLength(2); - expect(splitExpenses?.[1].amount).toBe(0); + expect(splitExpenses?.[1].amount).toBe(50); // New split gets remaining 50 from total 100 - 50 locked expect(splitExpenses?.[1].description).toBe('Test comment'); expect(splitExpenses?.[1].category).toBe('Food'); expect(splitExpenses?.[1].tags).toEqual(['lunch']); @@ -10429,6 +10519,7 @@ describe('actions/IOU', () => { tags: ['lunch'], created: DateUtils.getDBTime(), reimbursable: false, // Existing split - not reimbursable + isManuallyEdited: true, // Lock the existing split so new split gets remaining amount }, ], attendees: [], @@ -10453,7 +10544,7 @@ describe('actions/IOU', () => { // Verify: The new split should have reimbursable: false (not counted as out-of-pocket) expect(splitExpenses?.[1].reimbursable).toBe(false); - expect(splitExpenses?.[1].amount).toBe(0); + expect(splitExpenses?.[1].amount).toBe(50); // New split gets remaining 50 from total 100 - 50 locked expect(splitExpenses?.[1].description).toBe('Card transaction'); expect(splitExpenses?.[1].category).toBe('Food'); expect(splitExpenses?.[1].tags).toEqual(['lunch']); @@ -12380,6 +12471,270 @@ describe('actions/IOU', () => { expect(newPolicyRecentlyUsedTags[tagName].length).toBe(2); expect(newPolicyRecentlyUsedTags[tagName].at(0)).toBe(newTag); }); + + it('should call notifyNewAction after updating the money request tag', async () => { + const policy = createRandomPolicy(100); + const transactionThreadReport = {reportID: '23', parentReportID: '34'} as Report; + const parentReport = {reportID: '34', policyID: policy.id} as Report; + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}23`, transactionThreadReport); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}34`, parentReport); + + updateMoneyRequestTag({ + transactionID: '100', + transactionThreadReport, + parentReport, + tag: 'new tag', + policy, + policyTagList: {}, + policyRecentlyUsedTags: undefined, + policyCategories: undefined, + currentUserAccountIDParam: currentUserPersonalDetails.accountID, + currentUserEmailParam: currentUserPersonalDetails.email ?? '', + isASAPSubmitBetaEnabled: false, + parentReportNextStep: undefined, + }); + + expect(notifyNewAction).toHaveBeenCalledTimes(1); + }); + }); + + describe('updateMoneyRequestDate', () => { + it('should call notifyNewAction after updating the money request date', async () => { + const fakeReport: Report = { + ...createRandomReport(100, undefined), + type: CONST.REPORT.TYPE.EXPENSE, + policyID: '1', + }; + const fakeTransaction: Transaction = { + ...createRandomTransaction(100), + reportID: fakeReport.reportID, + amount: 100, + currency: CONST.CURRENCY.USD, + }; + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${fakeTransaction.transactionID}`, fakeTransaction); + + updateMoneyRequestDate({ + transactionID: fakeTransaction.transactionID, + transactionThreadReport: fakeReport, + parentReport: undefined, + transactions: {}, + transactionViolations: {}, + value: format(new Date(), CONST.DATE.FNS_FORMAT_STRING), + policy: { + id: '123', + role: CONST.POLICY.ROLE.USER, + type: CONST.POLICY.TYPE.TEAM, + name: '', + owner: '', + outputCurrency: '', + isPolicyExpenseChatEnabled: false, + }, + policyTags: {}, + policyCategories: {}, + currentUserAccountIDParam: 123, + currentUserEmailParam: 'existing@example.com', + isASAPSubmitBetaEnabled: false, + parentReportNextStep: undefined, + }); + + expect(notifyNewAction).toHaveBeenCalledTimes(1); + }); + }); + + describe('updateMoneyRequestMerchant', () => { + it('should call notifyNewAction after updating the money request merchant', async () => { + const fakeReport: Report = { + ...createRandomReport(100, undefined), + type: CONST.REPORT.TYPE.EXPENSE, + policyID: '1', + }; + const fakeTransaction: Transaction = { + ...createRandomTransaction(100), + reportID: fakeReport.reportID, + amount: 100, + currency: CONST.CURRENCY.USD, + }; + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${fakeTransaction.transactionID}`, fakeTransaction); + + updateMoneyRequestMerchant({ + transactionID: fakeTransaction.transactionID, + transactionThreadReport: fakeReport, + parentReport: undefined, + value: 'Test Merchant', + policy: { + id: '123', + role: CONST.POLICY.ROLE.USER, + type: CONST.POLICY.TYPE.TEAM, + name: '', + owner: '', + outputCurrency: '', + isPolicyExpenseChatEnabled: false, + }, + policyTagList: {}, + policyCategories: {}, + currentUserAccountIDParam: 123, + currentUserEmailParam: 'existing@example.com', + isASAPSubmitBetaEnabled: false, + parentReportNextStep: undefined, + }); + + expect(notifyNewAction).toHaveBeenCalledTimes(1); + }); + }); + + describe('updateMoneyRequestTaxRate', () => { + it('should call notifyNewAction after updating the money request tax rate', async () => { + const fakeReport: Report = { + ...createRandomReport(100, undefined), + type: CONST.REPORT.TYPE.EXPENSE, + policyID: '1', + }; + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${fakeReport.reportID}`, fakeReport); + + updateMoneyRequestTaxRate({ + transactionID: '1', + transactionThreadReport: fakeReport, + parentReport: undefined, + taxCode: 'id_TAX_RATE_1', + taxAmount: 5, + taxValue: '5%', + policy: { + id: '123', + role: CONST.POLICY.ROLE.USER, + type: CONST.POLICY.TYPE.TEAM, + name: '', + owner: '', + outputCurrency: '', + isPolicyExpenseChatEnabled: false, + }, + policyTagList: {}, + policyCategories: {}, + currentUserAccountIDParam: 123, + currentUserEmailParam: 'existing@example.com', + isASAPSubmitBetaEnabled: false, + parentReportNextStep: undefined, + }); + + expect(notifyNewAction).toHaveBeenCalledTimes(1); + }); + }); + + describe('updateMoneyRequestDescription', () => { + it('should call notifyNewAction after updating the money request description', async () => { + const fakeReport: Report = { + ...createRandomReport(100, undefined), + type: CONST.REPORT.TYPE.EXPENSE, + policyID: '1', + }; + const fakeTransaction: Transaction = { + ...createRandomTransaction(100), + reportID: fakeReport.reportID, + amount: 100, + currency: CONST.CURRENCY.USD, + }; + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${fakeTransaction.transactionID}`, fakeTransaction); + + updateMoneyRequestDescription({ + transactionID: fakeTransaction.transactionID, + transactionThreadReport: fakeReport, + parentReport: undefined, + comment: 'Test description', + policy: { + id: '123', + role: CONST.POLICY.ROLE.USER, + type: CONST.POLICY.TYPE.TEAM, + name: '', + owner: '', + outputCurrency: '', + isPolicyExpenseChatEnabled: false, + }, + policyTagList: {}, + policyCategories: {}, + currentUserAccountIDParam: 123, + currentUserEmailParam: 'existing@example.com', + isASAPSubmitBetaEnabled: false, + parentReportNextStep: undefined, + }); + + expect(notifyNewAction).toHaveBeenCalledTimes(1); + }); + }); + + describe('updateMoneyRequestDistanceRate', () => { + it('should call notifyNewAction after updating the money request distance rate', async () => { + const fakeReport: Report = { + ...createRandomReport(1, undefined), + type: CONST.REPORT.TYPE.EXPENSE, + policyID: '1', + }; + const fakePolicy = createRandomPolicy(123); + const fakeTransaction: Transaction = { + ...createRandomTransaction(1), + reportID: fakeReport.reportID, + amount: 10000, + currency: CONST.CURRENCY.USD, + comment: { + type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, + customUnit: { + name: CONST.CUSTOM_UNITS.NAME_DISTANCE, + quantity: 10, + }, + }, + }; + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${fakeTransaction.transactionID}`, fakeTransaction); + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}123`, fakePolicy); + + updateMoneyRequestDistanceRate({ + transactionID: fakeTransaction.transactionID, + transactionThreadReport: fakeReport, + parentReport: undefined, + rateID: 'rate_1', + policy: fakePolicy, + policyTagList: {}, + policyCategories: {}, + currentUserAccountIDParam: 123, + currentUserEmailParam: 'existing@example.com', + isASAPSubmitBetaEnabled: false, + parentReportNextStep: undefined, + }); + + expect(notifyNewAction).toHaveBeenCalledTimes(1); + }); + }); + + describe('updateMoneyRequestTaxAmount', () => { + it('should call notifyNewAction after updating the money request tax amount', async () => { + const fakeReport: Report = { + ...createRandomReport(1, undefined), + type: CONST.REPORT.TYPE.EXPENSE, + policyID: '1', + }; + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${fakeReport.reportID}`, fakeReport); + + updateMoneyRequestTaxAmount({ + transactionID: '1', + transactionThreadReport: fakeReport, + parentReport: undefined, + taxAmount: 10, + policy: { + id: '123', + role: CONST.POLICY.ROLE.USER, + type: CONST.POLICY.TYPE.TEAM, + name: '', + owner: '', + outputCurrency: '', + isPolicyExpenseChatEnabled: false, + }, + policyTagList: {}, + policyCategories: {}, + currentUserAccountIDParam: 123, + currentUserEmailParam: 'existing@example.com', + isASAPSubmitBetaEnabled: false, + parentReportNextStep: undefined, + }); + + expect(notifyNewAction).toHaveBeenCalledTimes(1); + }); }); describe('rejectMoneyRequest', () => { @@ -13576,6 +13931,43 @@ describe('actions/IOU', () => { }); }); + it('handleNavigateAfterExpenseCreate', async () => { + const mockedIsReportTopmostSplitNavigator = isReportTopmostSplitNavigator as jest.MockedFunction; + const spyOnMergeTransactionIdsHighlightOnSearchRoute = jest.spyOn(require('@libs/actions/Transaction'), 'mergeTransactionIdsHighlightOnSearchRoute'); + const activeReportID = '1'; + const transactionID = '1'; + mockedIsReportTopmostSplitNavigator.mockReturnValue(false); + + // When on the Inbox tab, or NOT from the "global create" button, or without a transactionID, + // the function dismissModalAndOpenReportInInboxTab will always be called to handle it, + // so mergeTransactionIdsHighlightOnSearchRoute will never be invoked. + handleNavigateAfterExpenseCreate({activeReportID, isFromGlobalCreate: false}); + expect(spyOnMergeTransactionIdsHighlightOnSearchRoute).toHaveBeenCalledTimes(0); + + handleNavigateAfterExpenseCreate({activeReportID, isFromGlobalCreate: true}); + expect(spyOnMergeTransactionIdsHighlightOnSearchRoute).toHaveBeenCalledTimes(0); + + mockedIsReportTopmostSplitNavigator.mockReturnValue(true); + handleNavigateAfterExpenseCreate({activeReportID, isFromGlobalCreate: true, transactionID}); + expect(spyOnMergeTransactionIdsHighlightOnSearchRoute).toHaveBeenCalledTimes(0); + + // When NOT on the Inbox tab + mockedIsReportTopmostSplitNavigator.mockReturnValue(false); + handleNavigateAfterExpenseCreate({activeReportID, isFromGlobalCreate: true, transactionID}); + + // then mergeTransactionIdsHighlightOnSearchRoute will be called + expect(spyOnMergeTransactionIdsHighlightOnSearchRoute).toHaveBeenCalledTimes(1); + expect(spyOnMergeTransactionIdsHighlightOnSearchRoute).toHaveBeenCalledWith(CONST.SEARCH.DATA_TYPES.EXPENSE, {[transactionID]: true}); + spyOnMergeTransactionIdsHighlightOnSearchRoute.mockClear(); + + // If expense is an invoice + handleNavigateAfterExpenseCreate({activeReportID, isFromGlobalCreate: true, transactionID, isInvoice: true}); + + expect(spyOnMergeTransactionIdsHighlightOnSearchRoute).toHaveBeenCalledTimes(1); + expect(spyOnMergeTransactionIdsHighlightOnSearchRoute).toHaveBeenCalledWith(CONST.SEARCH.DATA_TYPES.INVOICE, {[transactionID]: true}); + spyOnMergeTransactionIdsHighlightOnSearchRoute.mockReset(); + }); + describe('updateMoneyRequestDistance', () => { it('should update transaction with distance and waypoints', async () => { // Given a distance request transaction with valid waypoints and existing data diff --git a/tests/actions/PolicyTest.ts b/tests/actions/PolicyTest.ts index 043093e8f04f..9ce5e11b466e 100644 --- a/tests/actions/PolicyTest.ts +++ b/tests/actions/PolicyTest.ts @@ -291,7 +291,7 @@ describe('actions/Policy', () => { perDiem: true, reimbursements: true, expenses: true, - customUnits: true, + distance: true, invoices: true, exportLayouts: true, }, @@ -460,6 +460,56 @@ describe('actions/Policy', () => { } }); + it('duplicate workspace disabled options', async () => { + await Onyx.set(ONYXKEYS.SESSION, {email: ESH_EMAIL, accountID: ESH_ACCOUNT_ID}); + const fakePolicy = createRandomPolicy(12, CONST.POLICY.TYPE.TEAM); + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${fakePolicy.id}`, fakePolicy); + await waitForBatchedUpdates(); + + const policyID = Policy.generatePolicyID(); + + const options = { + policyName: 'Distance Disabled Workspace', + policyID: fakePolicy.id, + targetPolicyID: policyID, + welcomeNote: 'Join my policy', + parts: { + people: true, + reports: false, + connections: false, + categories: false, + tags: false, + taxes: false, + perDiem: false, + reimbursements: false, + expenses: false, + distance: false, + invoices: false, + exportLayouts: false, + }, + localCurrency: 'USD', + }; + + Policy.duplicateWorkspace(fakePolicy, options); + await waitForBatchedUpdates(); + + const policy: OnyxEntry = await new Promise((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + callback: (workspace) => { + Onyx.disconnect(connection); + resolve(workspace); + }, + }); + }); + + // When these parts are disabled, the corresponding policy settings should be false + expect(policy?.areWorkflowsEnabled).toBe(false); + expect(policy?.areDistanceRatesEnabled).toBe(false); + expect(policy?.areInvoicesEnabled).toBe(false); + expect(policy?.arePerDiemRatesEnabled).toBe(false); + }); + it('creates a new workspace with BASIC approval mode if the introSelected is MANAGE_TEAM', async () => { const policyID = Policy.generatePolicyID(); // When a new workspace is created with introSelected set to MANAGE_TEAM diff --git a/tests/actions/ReplaceOptimisticReportWithActualReportTest.ts b/tests/actions/ReplaceOptimisticReportWithActualReportTest.ts index e51669ca771b..66d6a7abae82 100644 --- a/tests/actions/ReplaceOptimisticReportWithActualReportTest.ts +++ b/tests/actions/ReplaceOptimisticReportWithActualReportTest.ts @@ -1,23 +1,48 @@ import {beforeAll, beforeEach, describe, expect, it} from '@jest/globals'; +import {DeviceEventEmitter} from 'react-native'; import Onyx from 'react-native-onyx'; import CONST from '@src/CONST'; import {replaceOptimisticReportWithActualReport} from '@src/libs/actions/replaceOptimisticReportWithActualReport'; import ONYXKEYS from '@src/ONYXKEYS'; +import SCREENS from '@src/SCREENS'; import createRandomReportAction from '../utils/collections/reportActions'; import {createRandomReport} from '../utils/collections/reports'; import getOnyxValue from '../utils/getOnyxValue'; import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; +type SwitchReportEventData = { + preexistingReportID: string; + reportToCopyDraftTo: string; + callback: () => void; +}; + +const mockNavigate = jest.fn(); +const mockSetParams = jest.fn(); +const mockIsReady = jest.fn(() => false); +const mockGetActiveRoute = jest.fn(() => ''); +const mockGetCurrentRoute = jest.fn(() => undefined as {name: string; params: Record} | undefined); + jest.mock('@libs/Navigation/Navigation', () => ({ - navigate: jest.fn(), - setParams: jest.fn(), - getActiveRoute: jest.fn(() => ''), + navigate: (...args: unknown[]) => mockNavigate(...args) as void, + setParams: (...args: unknown[]) => mockSetParams(...args) as void, + getActiveRoute: () => mockGetActiveRoute(), navigationRef: { - isReady: jest.fn(() => false), - getCurrentRoute: jest.fn(), + isReady: () => mockIsReady(), + getCurrentRoute: () => mockGetCurrentRoute(), }, })); +const mockOpenReport = jest.fn(); +jest.mock('@src/libs/actions/Report', () => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const originalModule = jest.requireActual('@src/libs/actions/Report'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return { + ...originalModule, + openReport: (...args: unknown[]) => mockOpenReport(...args) as void, + }; +}); + describe('replaceOptimisticReportWithActualReport', () => { beforeAll(() => { Onyx.init({ @@ -28,6 +53,13 @@ describe('replaceOptimisticReportWithActualReport', () => { beforeEach(async () => { await Onyx.clear(); await waitForBatchedUpdates(); + // Reset navigation mocks to default values + mockIsReady.mockReturnValue(false); + mockGetActiveRoute.mockReturnValue(''); + mockGetCurrentRoute.mockReturnValue(undefined); + mockNavigate.mockClear(); + mockSetParams.mockClear(); + mockOpenReport.mockClear(); }); it('should do nothing if reportID is missing', async () => { @@ -56,13 +88,43 @@ describe('replaceOptimisticReportWithActualReport', () => { expect(updatedReport).toBeUndefined(); }); + it('should be triggered automatically when report with preexistingReportID is set in Onyx', async () => { + // Given a preexisting report already in Onyx + const reportID = '1'; + const preexistingReportID = '2'; + + const existingReport = createRandomReport(Number(preexistingReportID), undefined); + existingReport.reportID = preexistingReportID; + + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${preexistingReportID}`, existingReport); + await waitForBatchedUpdates(); + + // When an optimistic report with preexistingReportID is set in Onyx + const optimisticReport = createRandomReport(Number(reportID), undefined); + optimisticReport.reportID = reportID; + optimisticReport.preexistingReportID = preexistingReportID; + + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, optimisticReport); + await waitForBatchedUpdates(); + + // Then the replacement should happen automatically via Onyx callback + const deletedReport = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); + expect(deletedReport).toBeFalsy(); + + // And the preexisting report should be updated with cleared preexistingReportID + const updatedReport = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}${preexistingReportID}`); + expect(updatedReport).toBeDefined(); + expect(updatedReport?.reportID).toBe(preexistingReportID); + expect(updatedReport?.preexistingReportID).toBeFalsy(); + }); + it('should clean up stale optimistic IOU report and its report preview', async () => { + // Given an optimistic IOU report with parent report info const parentReportID = '10'; const parentReportActionID = 'action123'; const reportID = '1'; const preexistingReportID = '2'; - // Create an IOU report with parent info const report = createRandomReport(1, undefined); report.reportID = reportID; report.preexistingReportID = preexistingReportID; @@ -73,33 +135,33 @@ describe('replaceOptimisticReportWithActualReport', () => { const parentAction = createRandomReportAction(1); parentAction.childReportID = reportID; - // Set up parent report with action await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentReportID}`, { [parentReportActionID]: parentAction, }); await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, report); await waitForBatchedUpdates(); + // When replaceOptimisticReportWithActualReport is called replaceOptimisticReportWithActualReport(report, undefined); await waitForBatchedUpdates(); - // Verify the parent report action is set to null (deleted) + // Then the parent report action should be deleted const parentActions = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentReportID}`); expect(parentActions?.[parentReportActionID]).toBeFalsy(); - // Verify the optimistic report is set to null (deleted) - Onyx returns undefined for deleted keys + // And the optimistic report should be deleted const deletedReport = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); expect(deletedReport).toBeFalsy(); }); it('should merge optimistic DM report into preexisting report without draft comment', async () => { + // Given an optimistic report and a preexisting report in Onyx const reportID = '1'; const preexistingReportID = '2'; const optimisticAccountID = 1; const existingAccountID = 2; - // Create optimistic and preexisting reports const optimisticReport = createRandomReport(Number(reportID), undefined); optimisticReport.reportID = reportID; optimisticReport.preexistingReportID = preexistingReportID; @@ -115,15 +177,16 @@ describe('replaceOptimisticReportWithActualReport', () => { await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${preexistingReportID}`, existingReport); await waitForBatchedUpdates(); + // When replaceOptimisticReportWithActualReport is called replaceOptimisticReportWithActualReport(optimisticReport, undefined); await waitForBatchedUpdates(); - // Verify optimistic report is deleted - Onyx returns undefined for deleted keys + // Then the optimistic report should be deleted const deletedReport = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); expect(deletedReport).toBeFalsy(); - // Verify preexisting report is updated with optimistic data but keeps existing participants + // And the preexisting report should be updated with optimistic data but keep existing participants const updatedReport = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}${preexistingReportID}`); expect(updatedReport).toBeDefined(); expect(updatedReport?.reportID).toBe(preexistingReportID); @@ -132,17 +195,16 @@ describe('replaceOptimisticReportWithActualReport', () => { }); it('should handle preexisting report when participants is undefined in existing report', async () => { + // Given an optimistic report with participants and an existing report without participants const reportID = '1'; const preexistingReportID = '2'; const accountID = 1; - // Create optimistic report with participants const optimisticReport = createRandomReport(Number(reportID), undefined); optimisticReport.reportID = reportID; optimisticReport.preexistingReportID = preexistingReportID; optimisticReport.participants = {[accountID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}}; - // Create existing report without participants const existingReport = createRandomReport(Number(preexistingReportID), undefined); existingReport.reportID = preexistingReportID; existingReport.participants = undefined; @@ -151,12 +213,1123 @@ describe('replaceOptimisticReportWithActualReport', () => { await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${preexistingReportID}`, existingReport); await waitForBatchedUpdates(); + // When replaceOptimisticReportWithActualReport is called replaceOptimisticReportWithActualReport(optimisticReport, undefined); await waitForBatchedUpdates(); - // Verify preexisting report uses optimistic report's participants when existing has none + // Then the preexisting report should use the optimistic report's participants const updatedReport = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}${preexistingReportID}`); expect(updatedReport?.participants).toEqual(optimisticReport.participants); }); + + it('should handle preexistingReportID for DM/group-DM and preserve participants', async () => { + // Given an optimistic DM report without parent action + const reportID = '1'; + const preexistingReportID = '2'; + const user1AccountID = 100; + const user2AccountID = 200; + + const optimisticReport = createRandomReport(Number(reportID), undefined); + optimisticReport.reportID = reportID; + optimisticReport.preexistingReportID = preexistingReportID; + optimisticReport.type = CONST.REPORT.TYPE.CHAT; + optimisticReport.participants = { + [user1AccountID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + [user2AccountID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + }; + + optimisticReport.parentReportActionID = undefined; + + const existingReport = createRandomReport(Number(preexistingReportID), undefined); + existingReport.reportID = preexistingReportID; + existingReport.type = CONST.REPORT.TYPE.CHAT; + existingReport.participants = { + [user1AccountID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.DAILY}, + [user2AccountID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.MUTE}, + }; + + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, optimisticReport); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${preexistingReportID}`, existingReport); + await waitForBatchedUpdates(); + + // When replaceOptimisticReportWithActualReport is called + replaceOptimisticReportWithActualReport(optimisticReport, undefined); + + await waitForBatchedUpdates(); + + // Then the optimistic report should be cleared + const deletedReport = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); + expect(deletedReport).toBeFalsy(); + + // And the preexisting report should preserve existing participants + const updatedReport = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}${preexistingReportID}`); + expect(updatedReport?.participants).toEqual(existingReport.participants); + expect(updatedReport?.preexistingReportID).toBeFalsy(); + }); + + it('should merge preexisting report data correctly', async () => { + // Given an optimistic report and a preexisting report with different data + const reportID = '1'; + const preexistingReportID = '2'; + const accountID = 100; + + const optimisticReport = createRandomReport(Number(reportID), undefined); + optimisticReport.reportID = reportID; + optimisticReport.preexistingReportID = preexistingReportID; + optimisticReport.reportName = 'Optimistic Chat'; + optimisticReport.lastReadTime = '2026-01-27T10:00:00Z'; + optimisticReport.lastVisibleActionCreated = '2026-01-27T10:00:00Z'; + + const existingReport = createRandomReport(Number(preexistingReportID), undefined); + existingReport.reportID = preexistingReportID; + existingReport.reportName = 'Original Chat'; + existingReport.lastReadTime = '2026-01-20T10:00:00Z'; + existingReport.lastVisibleActionCreated = '2026-01-20T10:00:00Z'; + existingReport.participants = {[accountID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}}; + + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, optimisticReport); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${preexistingReportID}`, existingReport); + await waitForBatchedUpdates(); + + // When replaceOptimisticReportWithActualReport is called + replaceOptimisticReportWithActualReport(optimisticReport, undefined); + + await waitForBatchedUpdates(); + + // Then the preexisting report should be updated with optimistic report data + const updatedReport = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}${preexistingReportID}`); + + // Report ID should be the preexisting one + expect(updatedReport?.reportID).toBe(preexistingReportID); + + // preexistingReportID should be cleared + expect(updatedReport?.preexistingReportID).toBeFalsy(); + + // Participants should be preserved from the existing report + expect(updatedReport?.participants).toEqual(existingReport.participants); + + // Other data from the optimistic report should be merged + expect(updatedReport?.reportName).toBe(optimisticReport.reportName); + }); + + it('should handle preexistingReportID for one-transaction report', async () => { + // Given an IOU report with one transaction and an optimistic transaction thread report + const iouReportID = '9999'; + const chatReportID = '8888'; + const optimisticReportID = '1234'; + const preexistingReportID = '5555'; + const iouReportAction = { + reportActionID: '1', + actionName: CONST.REPORT.ACTIONS.TYPE.IOU, + originalMessage: { + type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, + IOUTransactionID: 'trans123', + }, + childReportID: preexistingReportID, + }; + + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${iouReportID}`, { + reportID: iouReportID, + type: CONST.REPORT.TYPE.IOU, + chatReportID, + }); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${chatReportID}`, { + reportID: chatReportID, + type: CONST.REPORT.TYPE.CHAT, + }); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReportID}`, { + [iouReportAction.reportActionID]: iouReportAction, + }); + + // Create the optimistic report + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${optimisticReportID}`, { + reportID: optimisticReportID, + type: CONST.REPORT.TYPE.CHAT, + parentReportID: iouReportID, + parentReportActionID: '1', + }); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReportID}`, { + [iouReportAction.reportActionID]: { + childReportID: optimisticReportID, + }, + }); + + // When OpenReport API is called, it returns preexistingReportID and reportID + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${optimisticReportID}`, { + reportID: optimisticReportID, + preexistingReportID, + }); + + await waitForBatchedUpdates(); + + // When replaceOptimisticReportWithActualReport is called + const report = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}${optimisticReportID}`); + if (report) { + replaceOptimisticReportWithActualReport(report, undefined); + } + + await waitForBatchedUpdates(); + + // Then the optimistic report should be cleared + const optimisticReport = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}${optimisticReportID}`); + expect(optimisticReport).toBeFalsy(); + + // And the childReportID of the IOU action should be updated to the preexisting report + const iouReportActions = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReportID}`); + expect(iouReportActions?.['1']?.childReportID).toBe(preexistingReportID); + }); + + it('should handle preexistingReportID for multi-transaction report', async () => { + // Given an IOU report with multiple transactions and an optimistic transaction thread report + const iouReportID = '9999'; + const chatReportID = '8888'; + const optimisticReportID = '1234'; + const preexistingReportID = '5555'; + const iouReportAction1 = { + reportActionID: '1', + actionName: CONST.REPORT.ACTIONS.TYPE.IOU, + originalMessage: { + type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, + IOUTransactionID: 'trans123', + }, + childReportID: preexistingReportID, + }; + const iouReportAction2 = { + reportActionID: '2', + actionName: CONST.REPORT.ACTIONS.TYPE.IOU, + originalMessage: { + type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, + IOUTransactionID: 'trans456', + }, + childReportID: '6666', + }; + + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${iouReportID}`, { + reportID: iouReportID, + type: CONST.REPORT.TYPE.IOU, + chatReportID, + }); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${chatReportID}`, { + reportID: chatReportID, + type: CONST.REPORT.TYPE.CHAT, + }); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReportID}`, { + [iouReportAction1.reportActionID]: iouReportAction1, + [iouReportAction2.reportActionID]: iouReportAction2, + }); + + // Given that we create an optimistic transaction thread report + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${optimisticReportID}`, { + reportID: optimisticReportID, + type: CONST.REPORT.TYPE.CHAT, + parentReportID: iouReportID, + parentReportActionID: '1', + }); + + // Given that we update the childReportID of the first IOU action points to the optimistic report + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReportID}`, { + [iouReportAction1.reportActionID]: { + childReportID: optimisticReportID, + }, + }); + + // When OpenReport API is called, it returns preexistingReportID and reportID + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${optimisticReportID}`, { + reportID: optimisticReportID, + preexistingReportID, + }); + + await waitForBatchedUpdates(); + + // When replaceOptimisticReportWithActualReport is called + const report = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}${optimisticReportID}`); + if (report) { + replaceOptimisticReportWithActualReport(report, undefined); + } + + await waitForBatchedUpdates(); + + // Then the optimistic report should be cleared + const optimisticReport = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}${optimisticReportID}`); + expect(optimisticReport).toBeFalsy(); + + // And the childReportID of the IOU action should be updated to the preexisting report + const iouReportActions = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReportID}`); + expect(iouReportActions?.[iouReportAction1.reportActionID]?.childReportID).toBe(preexistingReportID); + }); + + it('should handle preexistingReportID for thread under comment', async () => { + // Given a parent chat report with a comment and an optimistic thread report + const chatReportID = '9999'; + const optimisticReportID = '1234'; + const preexistingReportID = '5555'; + const commentReportAction = { + reportActionID: '1', + actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, + message: [{type: 'TEXT', text: 'Test comment'}], + childReportID: preexistingReportID, + }; + + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${chatReportID}`, { + reportID: chatReportID, + type: CONST.REPORT.TYPE.CHAT, + }); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReportID}`, { + [commentReportAction.reportActionID]: commentReportAction, + }); + + // Given that we create an optimistic thread report + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${optimisticReportID}`, { + reportID: optimisticReportID, + type: CONST.REPORT.TYPE.CHAT, + parentReportID: chatReportID, + parentReportActionID: '1', + }); + + // Given that we update the childReportID of the comment action points to the optimistic report + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReportID}`, { + [commentReportAction.reportActionID]: { + childReportID: optimisticReportID, + }, + }); + + // When OpenReport API is called, it returns preexistingReportID and reportID + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${optimisticReportID}`, { + reportID: optimisticReportID, + preexistingReportID, + }); + + await waitForBatchedUpdates(); + + // When replaceOptimisticReportWithActualReport is called + const report = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}${optimisticReportID}`); + if (report) { + replaceOptimisticReportWithActualReport(report, undefined); + } + + await waitForBatchedUpdates(); + + // Then the optimistic report should be cleared + const optimisticReport = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}${optimisticReportID}`); + expect(optimisticReport).toBeFalsy(); + + // And the parent report action should be updated to point to the preexisting thread + const parentChatReportActions = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReportID}`); + expect(parentChatReportActions?.['1']?.childReportID).toBe(preexistingReportID); + }); + + it('should transfer draft comment to preexisting report when the user is not on the report', async () => { + // Given an optimistic DM report with a draft comment and user on a different report + const reportID = '1'; + const preexistingReportID = '2'; + const draftComment = 'This is a draft message'; + + mockIsReady.mockReturnValue(true); + mockGetActiveRoute.mockReturnValue('/r/999'); + + const optimisticReport = createRandomReport(Number(reportID), undefined); + optimisticReport.reportID = reportID; + optimisticReport.preexistingReportID = preexistingReportID; + + const existingReport = createRandomReport(Number(preexistingReportID), undefined); + existingReport.reportID = preexistingReportID; + + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, optimisticReport); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${preexistingReportID}`, existingReport); + await waitForBatchedUpdates(); + + // When replaceOptimisticReportWithActualReport is called with a draft comment + replaceOptimisticReportWithActualReport(optimisticReport, draftComment); + + await waitForBatchedUpdates(); + + // Then the draft comment should be transferred to the preexisting report + const preexistingDraftComment = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${preexistingReportID}`); + expect(preexistingDraftComment).toBe(draftComment); + + // And the optimistic report should be cleared + const deletedReport = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); + expect(deletedReport).toBeFalsy(); + }); + + it('should transfer draft comment to preexisting thread report for transaction thread', async () => { + // Given an optimistic transaction thread with a parent IOU report and user on a different report + const iouReportID = '9999'; + const chatReportID = '8888'; + const optimisticReportID = '1234'; + const preexistingReportID = '5555'; + const draftComment = 'Draft for transaction thread'; + + // Mock navigation to be ready + mockIsReady.mockReturnValue(true); + + // User is on a different report + mockGetActiveRoute.mockReturnValue('/r/999'); + + const iouReportAction = { + reportActionID: '1', + actionName: CONST.REPORT.ACTIONS.TYPE.IOU, + originalMessage: { + type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, + IOUTransactionID: 'trans123', + }, + childReportID: preexistingReportID, + }; + + // Create an IOU report + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${iouReportID}`, { + reportID: iouReportID, + type: CONST.REPORT.TYPE.IOU, + chatReportID, + }); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${chatReportID}`, { + reportID: chatReportID, + type: CONST.REPORT.TYPE.CHAT, + }); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReportID}`, { + [iouReportAction.reportActionID]: iouReportAction, + }); + + // Create the preexisting thread report + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${preexistingReportID}`, { + reportID: preexistingReportID, + type: CONST.REPORT.TYPE.CHAT, + parentReportID: iouReportID, + parentReportActionID: '1', + }); + + // Create the optimistic transaction thread report + const optimisticReport = { + reportID: optimisticReportID, + type: CONST.REPORT.TYPE.CHAT, + parentReportID: iouReportID, + parentReportActionID: '1', + preexistingReportID, + }; + + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${optimisticReportID}`, optimisticReport); + await waitForBatchedUpdates(); + + // When replaceOptimisticReportWithActualReport is called with a draft comment + replaceOptimisticReportWithActualReport(optimisticReport, draftComment); + + await waitForBatchedUpdates(); + + // Then the draft comment should be transferred to the preexisting thread report + const preexistingDraftComment = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${preexistingReportID}`); + expect(preexistingDraftComment).toBe(draftComment); + + // And the optimistic report should be cleared + const deletedReport = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}${optimisticReportID}`); + expect(deletedReport).toBeFalsy(); + }); + + it('should transfer draft comment to parent IOU report for one-transaction report when user is not on report', async () => { + // Given an optimistic transaction thread under a one-transaction IOU report with user on a different report + const iouReportID = '9999'; + const optimisticReportID = '1234'; + const preexistingReportID = '5555'; + const draftComment = 'Draft for one-transaction thread'; + + mockIsReady.mockReturnValue(true); + mockGetActiveRoute.mockReturnValue('/r/999'); + mockGetCurrentRoute.mockReturnValue({name: SCREENS.REPORT, params: {}}); + + const reportActionID = '1'; + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${iouReportID}`, { + reportID: iouReportID, + type: CONST.REPORT.TYPE.IOU, + transactionCount: 1, + }); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReportID}`, { + [reportActionID]: { + reportActionID, + actionName: CONST.REPORT.ACTIONS.TYPE.IOU, + originalMessage: {type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, IOUTransactionID: 'trans123'}, + }, + }); + + const optimisticReport = { + reportID: optimisticReportID, + type: CONST.REPORT.TYPE.CHAT, + parentReportID: iouReportID, + parentReportActionID: reportActionID, + preexistingReportID, + }; + + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${optimisticReportID}`, optimisticReport); + await waitForBatchedUpdates(); + + // When replaceOptimisticReportWithActualReport is called with a draft comment + replaceOptimisticReportWithActualReport(optimisticReport, draftComment); + + await waitForBatchedUpdates(); + + // Then the draft comment should be transferred to the parent IOU report + const parentDraft = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${iouReportID}`); + expect(parentDraft).toBe(draftComment); + + // And the preexisting thread should NOT have the draft + const preexistingDraft = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${preexistingReportID}`); + expect(preexistingDraft).toBeFalsy(); + }); + + it('should clear optimistic report draft comment after transfer', async () => { + // Given an optimistic report with a draft comment stored in Onyx + const reportID = '1'; + const preexistingReportID = '2'; + const draftComment = 'Draft to be cleared'; + + const optimisticReport = createRandomReport(Number(reportID), undefined); + optimisticReport.reportID = reportID; + optimisticReport.preexistingReportID = preexistingReportID; + + const existingReport = createRandomReport(Number(preexistingReportID), undefined); + existingReport.reportID = preexistingReportID; + + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, optimisticReport); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${preexistingReportID}`, existingReport); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${reportID}`, draftComment); + await waitForBatchedUpdates(); + + // When replaceOptimisticReportWithActualReport is called with the draft comment + replaceOptimisticReportWithActualReport(optimisticReport, draftComment); + + await waitForBatchedUpdates(); + + // Then the optimistic report's draft comment should be cleared from Onyx + const optimisticDraftComment = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${reportID}`); + expect(optimisticDraftComment).toBeFalsy(); + }); + + it('should navigate to preexisting report when user is focused on optimistic DM report', async () => { + // Given an optimistic DM report with the user currently viewing it + const reportID = '1'; + const preexistingReportID = '2'; + + mockIsReady.mockReturnValue(true); + mockGetActiveRoute.mockReturnValue(`/r/${reportID}`); + mockGetCurrentRoute.mockReturnValue({name: SCREENS.REPORT, params: {}}); + + const optimisticReport = createRandomReport(Number(reportID), undefined); + optimisticReport.reportID = reportID; + optimisticReport.preexistingReportID = preexistingReportID; + + const existingReport = createRandomReport(Number(preexistingReportID), undefined); + existingReport.reportID = preexistingReportID; + + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, optimisticReport); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${preexistingReportID}`, existingReport); + await waitForBatchedUpdates(); + + let capturedEventData: SwitchReportEventData | undefined; + const subscription = DeviceEventEmitter.addListener(`switchToPreExistingReport_${reportID}`, (data: SwitchReportEventData) => { + capturedEventData = data; + }); + + // When replaceOptimisticReportWithActualReport is called + replaceOptimisticReportWithActualReport(optimisticReport, undefined); + await waitForBatchedUpdates(); + + // Then the switchToPreExistingReport event should be emitted + expect(capturedEventData).toBeDefined(); + expect(capturedEventData).toMatchObject({ + preexistingReportID, + reportToCopyDraftTo: preexistingReportID, + }); + + // And when the callback is executed (simulating ComposerWithSuggestions behavior) + capturedEventData?.callback(); + await waitForBatchedUpdates(); + + // Then the navigation should update to the preexisting report + expect(mockSetParams).toHaveBeenCalledWith({reportID: preexistingReportID}); + + subscription.remove(); + }); + + it('should navigate to parent IOU report when user is focused on transaction thread of one-transaction report', async () => { + // Given an optimistic transaction thread under a one-transaction IOU report with the user viewing it + const iouReportID = '9999'; + const optimisticReportID = '1234'; + const preexistingReportID = '5555'; + + mockIsReady.mockReturnValue(true); + mockGetActiveRoute.mockReturnValue(`/r/${optimisticReportID}`); + mockGetCurrentRoute.mockReturnValue({name: SCREENS.REPORT, params: {}}); + + const iouReportAction = { + reportActionID: '1', + actionName: CONST.REPORT.ACTIONS.TYPE.IOU, + originalMessage: { + type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, + IOUTransactionID: 'trans123', + }, + }; + + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${iouReportID}`, { + reportID: iouReportID, + type: CONST.REPORT.TYPE.IOU, + transactionCount: 1, + }); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReportID}`, { + [iouReportAction.reportActionID]: iouReportAction, + }); + + const optimisticReport = { + reportID: optimisticReportID, + type: CONST.REPORT.TYPE.CHAT, + parentReportID: iouReportID, + parentReportActionID: '1', + preexistingReportID, + }; + + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${optimisticReportID}`, optimisticReport); + await waitForBatchedUpdates(); + + let capturedEventData: SwitchReportEventData | undefined; + const subscription = DeviceEventEmitter.addListener(`switchToPreExistingReport_${optimisticReportID}`, (data: SwitchReportEventData) => { + capturedEventData = data; + }); + + // When replaceOptimisticReportWithActualReport is called and the callback is executed + replaceOptimisticReportWithActualReport(optimisticReport, undefined); + await waitForBatchedUpdates(); + + capturedEventData?.callback(); + await waitForBatchedUpdates(); + + // Then the navigation should go to the parent IOU report, not the preexisting thread + expect(mockSetParams).toHaveBeenCalledWith({reportID: iouReportID}); + + subscription.remove(); + }); + + it('should navigate to preexisting thread when user is focused on thread under comment', async () => { + // Given an optimistic thread under a chat comment with the user viewing it + const chatReportID = '9999'; + const optimisticReportID = '1234'; + const preexistingReportID = '5555'; + const reportActionID = '1'; + + mockIsReady.mockReturnValue(true); + mockGetActiveRoute.mockReturnValue(`/r/${optimisticReportID}`); + mockGetCurrentRoute.mockReturnValue({name: SCREENS.REPORT, params: {}}); + + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${chatReportID}`, { + reportID: chatReportID, + type: CONST.REPORT.TYPE.CHAT, + }); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReportID}`, { + [reportActionID]: { + reportActionID, + actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, + childReportID: optimisticReportID, + }, + }); + + const optimisticReport = { + reportID: optimisticReportID, + type: CONST.REPORT.TYPE.CHAT, + parentReportID: chatReportID, + parentReportActionID: reportActionID, + preexistingReportID, + }; + + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${optimisticReportID}`, optimisticReport); + await waitForBatchedUpdates(); + + let capturedEventData: SwitchReportEventData | undefined; + const subscription = DeviceEventEmitter.addListener(`switchToPreExistingReport_${optimisticReportID}`, (data: SwitchReportEventData) => { + capturedEventData = data; + }); + + // When replaceOptimisticReportWithActualReport is called and the callback is executed + replaceOptimisticReportWithActualReport(optimisticReport, undefined); + await waitForBatchedUpdates(); + + capturedEventData?.callback(); + await waitForBatchedUpdates(); + + // Then the navigation should go to the preexisting thread + expect(mockSetParams).toHaveBeenCalledWith({reportID: preexistingReportID}); + + subscription.remove(); + }); + + it('should navigate to preexisting thread when user is focused on transaction thread of multi-transaction report', async () => { + // Given an optimistic transaction thread under a multi-transaction IOU report with the user viewing it + const iouReportID = '9999'; + const optimisticReportID = '1234'; + const preexistingReportID = '5555'; + + mockIsReady.mockReturnValue(true); + mockGetActiveRoute.mockReturnValue(`/r/${optimisticReportID}`); + mockGetCurrentRoute.mockReturnValue({name: SCREENS.REPORT, params: {}}); + + const reportActionID1 = '1'; + const reportActionID2 = '2'; + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${iouReportID}`, { + reportID: iouReportID, + type: CONST.REPORT.TYPE.IOU, + transactionCount: 2, + }); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReportID}`, { + [reportActionID1]: { + reportActionID: reportActionID1, + actionName: CONST.REPORT.ACTIONS.TYPE.IOU, + originalMessage: {type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, IOUTransactionID: 'trans1'}, + }, + [reportActionID2]: { + reportActionID: reportActionID2, + actionName: CONST.REPORT.ACTIONS.TYPE.IOU, + originalMessage: {type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, IOUTransactionID: 'trans2'}, + }, + }); + + const optimisticReport = { + reportID: optimisticReportID, + type: CONST.REPORT.TYPE.CHAT, + parentReportID: iouReportID, + parentReportActionID: '1', + preexistingReportID, + }; + + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${optimisticReportID}`, optimisticReport); + await waitForBatchedUpdates(); + + let capturedEventData: SwitchReportEventData | undefined; + const subscription = DeviceEventEmitter.addListener(`switchToPreExistingReport_${optimisticReportID}`, (data: SwitchReportEventData) => { + capturedEventData = data; + }); + + // When replaceOptimisticReportWithActualReport is called and the callback is executed + replaceOptimisticReportWithActualReport(optimisticReport, undefined); + await waitForBatchedUpdates(); + + capturedEventData?.callback(); + await waitForBatchedUpdates(); + + // Then the navigation should go to the preexisting thread, NOT the parent IOU report + expect(mockSetParams).toHaveBeenCalledWith({reportID: preexistingReportID}); + + subscription.remove(); + }); + + it('should update backTo route when optimistic report is in background on expense report screen', async () => { + // Given an optimistic report in the background while user is on expense report screen with backTo pointing to it + const reportID = '1'; + const preexistingReportID = '2'; + + mockIsReady.mockReturnValue(true); + mockGetActiveRoute.mockReturnValue('/e/999'); + mockGetCurrentRoute.mockReturnValue({ + name: SCREENS.RIGHT_MODAL.EXPENSE_REPORT, + params: {backTo: `/r/${reportID}`}, + }); + + const optimisticReport = createRandomReport(Number(reportID), undefined); + optimisticReport.reportID = reportID; + optimisticReport.preexistingReportID = preexistingReportID; + + const existingReport = createRandomReport(Number(preexistingReportID), undefined); + existingReport.reportID = preexistingReportID; + + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, optimisticReport); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${preexistingReportID}`, existingReport); + await waitForBatchedUpdates(); + + let capturedEventData: SwitchReportEventData | undefined; + const subscription = DeviceEventEmitter.addListener(`switchToPreExistingReport_${reportID}`, (data: SwitchReportEventData) => { + capturedEventData = data; + }); + + // When replaceOptimisticReportWithActualReport is called and the callback is executed + replaceOptimisticReportWithActualReport(optimisticReport, undefined); + await waitForBatchedUpdates(); + + capturedEventData?.callback(); + await waitForBatchedUpdates(); + + // Then the navigation should update the backTo route with the preexisting report ID + expect(mockNavigate).toHaveBeenCalledWith(`/r/${preexistingReportID}`); + + subscription.remove(); + }); + + it('should emit event with parent report as reportToCopyDraftTo for one-transaction report', async () => { + // Given an optimistic transaction thread under a one-transaction IOU report with the user viewing it + const iouReportID = '9999'; + const optimisticReportID = '1234'; + const preexistingReportID = '5555'; + const reportActionID = '1'; + + mockIsReady.mockReturnValue(true); + mockGetActiveRoute.mockReturnValue(`/r/${optimisticReportID}`); + mockGetCurrentRoute.mockReturnValue({name: SCREENS.REPORT, params: {}}); + + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${iouReportID}`, { + reportID: iouReportID, + type: CONST.REPORT.TYPE.IOU, + transactionCount: 1, + }); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReportID}`, { + [reportActionID]: { + reportActionID, + actionName: CONST.REPORT.ACTIONS.TYPE.IOU, + originalMessage: {type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, IOUTransactionID: 'trans123'}, + }, + }); + + const optimisticReport = { + reportID: optimisticReportID, + type: CONST.REPORT.TYPE.CHAT, + parentReportID: iouReportID, + parentReportActionID: reportActionID, + preexistingReportID, + }; + + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${optimisticReportID}`, optimisticReport); + await waitForBatchedUpdates(); + + let capturedEventData: SwitchReportEventData | undefined; + const subscription = DeviceEventEmitter.addListener(`switchToPreExistingReport_${optimisticReportID}`, (data: SwitchReportEventData) => { + capturedEventData = data; + }); + + // When replaceOptimisticReportWithActualReport is called + replaceOptimisticReportWithActualReport(optimisticReport, undefined); + await waitForBatchedUpdates(); + + // Then the emitted event should have the parent IOU report as reportToCopyDraftTo + expect(capturedEventData?.reportToCopyDraftTo).toBe(iouReportID); + + subscription.remove(); + }); + + it('should NOT emit switchToPreExistingReport event when user is not on optimistic report', async () => { + // Given an optimistic report with the user on a completely different report + const reportID = '1'; + const preexistingReportID = '2'; + + mockIsReady.mockReturnValue(true); + mockGetActiveRoute.mockReturnValue('/r/999'); + mockGetCurrentRoute.mockReturnValue({name: SCREENS.REPORT, params: {}}); + + const optimisticReport = createRandomReport(Number(reportID), undefined); + optimisticReport.reportID = reportID; + optimisticReport.preexistingReportID = preexistingReportID; + + const existingReport = createRandomReport(Number(preexistingReportID), undefined); + existingReport.reportID = preexistingReportID; + + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, optimisticReport); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${preexistingReportID}`, existingReport); + await waitForBatchedUpdates(); + + let capturedEventData: SwitchReportEventData | undefined; + const subscription = DeviceEventEmitter.addListener(`switchToPreExistingReport_${reportID}`, (data: SwitchReportEventData) => { + capturedEventData = data; + }); + + // When replaceOptimisticReportWithActualReport is called + replaceOptimisticReportWithActualReport(optimisticReport, undefined); + await waitForBatchedUpdates(); + + // Then the switchToPreExistingReport event should NOT be emitted + expect(capturedEventData).toBeUndefined(); + + // But the optimistic report should still be cleared + const deletedReport = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); + expect(deletedReport).toBeFalsy(); + + subscription.remove(); + }); + + it('should call openReport when user is already on parent one expense report', async () => { + // Given an optimistic transaction thread under a one-transaction IOU report with the user on the parent IOU report + const iouReportID = '9999'; + const optimisticReportID = '1234'; + const preexistingReportID = '5555'; + + mockIsReady.mockReturnValue(true); + mockGetActiveRoute.mockReturnValue(`/r/${iouReportID}`); + mockGetCurrentRoute.mockReturnValue({name: SCREENS.REPORT, params: {}}); + + const reportActionID = '1'; + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${iouReportID}`, { + reportID: iouReportID, + type: CONST.REPORT.TYPE.IOU, + transactionCount: 1, + }); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReportID}`, { + [reportActionID]: { + reportActionID, + actionName: CONST.REPORT.ACTIONS.TYPE.IOU, + originalMessage: {type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, IOUTransactionID: 'trans123'}, + }, + }); + + const optimisticReport = { + reportID: optimisticReportID, + type: CONST.REPORT.TYPE.CHAT, + parentReportID: iouReportID, + parentReportActionID: reportActionID, + preexistingReportID, + }; + + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${optimisticReportID}`, optimisticReport); + await waitForBatchedUpdates(); + + // When replaceOptimisticReportWithActualReport is called + replaceOptimisticReportWithActualReport(optimisticReport, undefined); + await waitForBatchedUpdates(); + + // Then openReport should be called with the parent IOU report ID + expect(mockOpenReport).toHaveBeenCalledWith(iouReportID); + + // And the optimistic report should be cleared + const deletedReport = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}${optimisticReportID}`); + expect(deletedReport).toBeFalsy(); + }); + + it('should transfer draft to parent IOU report and call openReport when user is already on parent one expense report', async () => { + // Given an optimistic transaction thread with a draft comment under a one-transaction IOU report with the user on the parent + const iouReportID = '9999'; + const optimisticReportID = '1234'; + const preexistingReportID = '5555'; + const draftComment = 'Draft while on parent report'; + + mockIsReady.mockReturnValue(true); + mockGetActiveRoute.mockReturnValue(`/r/${iouReportID}`); + mockGetCurrentRoute.mockReturnValue({name: SCREENS.REPORT, params: {}}); + + const reportActionID = '1'; + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${iouReportID}`, { + reportID: iouReportID, + type: CONST.REPORT.TYPE.IOU, + transactionCount: 1, + }); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReportID}`, { + [reportActionID]: { + reportActionID, + actionName: CONST.REPORT.ACTIONS.TYPE.IOU, + originalMessage: {type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, IOUTransactionID: 'trans123'}, + }, + }); + + const optimisticReport = { + reportID: optimisticReportID, + type: CONST.REPORT.TYPE.CHAT, + parentReportID: iouReportID, + parentReportActionID: reportActionID, + preexistingReportID, + }; + + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${optimisticReportID}`, optimisticReport); + await waitForBatchedUpdates(); + + // When replaceOptimisticReportWithActualReport is called with a draft comment + replaceOptimisticReportWithActualReport(optimisticReport, draftComment); + await waitForBatchedUpdates(); + + // Then the draft should be transferred to the parent IOU report + const parentDraft = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${iouReportID}`); + expect(parentDraft).toBe(draftComment); + + // And openReport should be called after the draft is saved + expect(mockOpenReport).toHaveBeenCalledWith(iouReportID); + + // And the optimistic report should be cleared + const deletedReport = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}${optimisticReportID}`); + expect(deletedReport).toBeFalsy(); + }); + + it('should call openReport when user is on search report view of parent one expense report', async () => { + // Given an optimistic transaction thread under a one-transaction IOU report with the user on the search report view + const iouReportID = '9999'; + const optimisticReportID = '1234'; + const preexistingReportID = '5555'; + + mockIsReady.mockReturnValue(true); + mockGetActiveRoute.mockReturnValue(`/search/view/${iouReportID}`); + mockGetCurrentRoute.mockReturnValue({name: SCREENS.SEARCH.MONEY_REQUEST_REPORT, params: {}}); + + const reportActionID = '1'; + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${iouReportID}`, { + reportID: iouReportID, + type: CONST.REPORT.TYPE.IOU, + transactionCount: 1, + }); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReportID}`, { + [reportActionID]: { + reportActionID, + actionName: CONST.REPORT.ACTIONS.TYPE.IOU, + originalMessage: {type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, IOUTransactionID: 'trans123'}, + }, + }); + + const optimisticReport = { + reportID: optimisticReportID, + type: CONST.REPORT.TYPE.CHAT, + parentReportID: iouReportID, + parentReportActionID: reportActionID, + preexistingReportID, + }; + + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${optimisticReportID}`, optimisticReport); + await waitForBatchedUpdates(); + + // When replaceOptimisticReportWithActualReport is called + replaceOptimisticReportWithActualReport(optimisticReport, undefined); + await waitForBatchedUpdates(); + + // Then openReport should be called with the parent IOU report ID + expect(mockOpenReport).toHaveBeenCalledWith(iouReportID); + }); + + it('should transfer draft to parent IOU report and call openReport when user is on search report view with draft comment', async () => { + // Given an optimistic transaction thread with a draft comment under a one-transaction IOU report with the user on search view + const iouReportID = '9999'; + const optimisticReportID = '1234'; + const preexistingReportID = '5555'; + const draftComment = 'Draft while on search view'; + + mockIsReady.mockReturnValue(true); + mockGetActiveRoute.mockReturnValue(`/search/view/${iouReportID}`); + mockGetCurrentRoute.mockReturnValue({name: SCREENS.SEARCH.MONEY_REQUEST_REPORT, params: {}}); + + const reportActionID = '1'; + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${iouReportID}`, { + reportID: iouReportID, + type: CONST.REPORT.TYPE.IOU, + transactionCount: 1, + }); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReportID}`, { + [reportActionID]: { + reportActionID, + actionName: CONST.REPORT.ACTIONS.TYPE.IOU, + originalMessage: {type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, IOUTransactionID: 'trans123'}, + }, + }); + + const optimisticReport = { + reportID: optimisticReportID, + type: CONST.REPORT.TYPE.CHAT, + parentReportID: iouReportID, + parentReportActionID: reportActionID, + preexistingReportID, + }; + + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${optimisticReportID}`, optimisticReport); + await waitForBatchedUpdates(); + + // When replaceOptimisticReportWithActualReport is called with a draft comment + replaceOptimisticReportWithActualReport(optimisticReport, draftComment); + await waitForBatchedUpdates(); + + // Then the draft should be transferred to the parent IOU report + const parentDraft = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${iouReportID}`); + expect(parentDraft).toBe(draftComment); + + // And openReport should be called after the draft is saved + expect(mockOpenReport).toHaveBeenCalledWith(iouReportID); + + // And the optimistic report should be cleared + const deletedReport = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}${optimisticReportID}`); + expect(deletedReport).toBeFalsy(); + }); + + it('should clean up optimistic IOU report action when money request report has preexistingReportID', async () => { + // Given an optimistic transaction thread with an optimistic parent IOU report action + const iouReportID = '9999'; + const optimisticReportID = '1234'; + const preexistingReportID = '5555'; + const reportActionID = 'action123'; + + // Create the parent IOU report + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${iouReportID}`, { + reportID: iouReportID, + type: CONST.REPORT.TYPE.IOU, + }); + + // Create the parent IOU action with isOptimisticAction = true + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReportID}`, { + [reportActionID]: { + reportActionID, + actionName: CONST.REPORT.ACTIONS.TYPE.IOU, + originalMessage: {type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, IOUTransactionID: 'trans123'}, + childReportID: optimisticReportID, + isOptimisticAction: true, + }, + }); + + // Create the optimistic transaction thread report + const optimisticReport = { + reportID: optimisticReportID, + type: CONST.REPORT.TYPE.CHAT, + parentReportID: iouReportID, + parentReportActionID: reportActionID, + preexistingReportID, + }; + + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${optimisticReportID}`, optimisticReport); + await waitForBatchedUpdates(); + + // When replaceOptimisticReportWithActualReport is called + replaceOptimisticReportWithActualReport(optimisticReport, undefined); + await waitForBatchedUpdates(); + + // Then the optimistic parent IOU action should be deleted + const parentActions = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReportID}`); + expect(parentActions?.[reportActionID]).toBeFalsy(); + }); + + it('should NOT clean up non-optimistic IOU report action when money request report has preexistingReportID', async () => { + // Given an optimistic transaction thread with a non-optimistic parent IOU report action + const iouReportID = '9999'; + const optimisticReportID = '1234'; + const preexistingReportID = '5555'; + const reportActionID = 'action123'; + + // Create the parent IOU report + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${iouReportID}`, { + reportID: iouReportID, + type: CONST.REPORT.TYPE.IOU, + }); + + // Create the parent IOU action without isOptimisticAction + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReportID}`, { + [reportActionID]: { + reportActionID, + actionName: CONST.REPORT.ACTIONS.TYPE.IOU, + originalMessage: {type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, IOUTransactionID: 'trans123'}, + childReportID: optimisticReportID, + }, + }); + + // Create the optimistic transaction thread report + const optimisticReport = { + reportID: optimisticReportID, + type: CONST.REPORT.TYPE.CHAT, + parentReportID: iouReportID, + parentReportActionID: reportActionID, + preexistingReportID, + }; + + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${optimisticReportID}`, optimisticReport); + await waitForBatchedUpdates(); + + // When replaceOptimisticReportWithActualReport is called + replaceOptimisticReportWithActualReport(optimisticReport, undefined); + await waitForBatchedUpdates(); + + // Then the non-optimistic parent IOU action should NOT be deleted + const parentActions = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReportID}`); + expect(parentActions?.[reportActionID]).toBeDefined(); + + // And it should be updated to point to the preexisting report + expect(parentActions?.[reportActionID]?.childReportID).toBe(preexistingReportID); + }); }); diff --git a/tests/actions/ReportTest.ts b/tests/actions/ReportTest.ts index 5ce96d5e913f..c1a1556a688b 100644 --- a/tests/actions/ReportTest.ts +++ b/tests/actions/ReportTest.ts @@ -490,7 +490,6 @@ describe('actions/Report', () => { person: [{type: 'TEXT', style: 'strong', text: 'Test User'}], shouldShow: true, created: DateUtils.getDBTime(Date.now() - 3), - reportID: REPORT_ID, }; const optimisticReportActions: OnyxUpdate = { diff --git a/tests/ui/WorkspaceUpgradeTest.tsx b/tests/ui/WorkspaceUpgradeTest.tsx index 6dce53e9f63e..0df71d31cce1 100644 --- a/tests/ui/WorkspaceUpgradeTest.tsx +++ b/tests/ui/WorkspaceUpgradeTest.tsx @@ -6,6 +6,7 @@ import Onyx from 'react-native-onyx'; import ComposeProviders from '@components/ComposeProviders'; import HTMLEngineProvider from '@components/HTMLEngineProvider'; import {LocaleContextProvider} from '@components/LocaleContextProvider'; +import OnyxListItemProvider from '@components/OnyxListItemProvider'; import {WRITE_COMMANDS} from '@libs/API/types'; import {convertToShortDisplayString} from '@libs/CurrencyUtils'; import createPlatformStackNavigator from '@libs/Navigation/PlatformStackNavigation/createPlatformStackNavigator'; @@ -37,7 +38,7 @@ const Stack = createPlatformStackNavigator(); const renderPage = (initialRouteName: typeof SCREENS.WORKSPACE.UPGRADE, initialParams: SettingsNavigatorParamList[typeof SCREENS.WORKSPACE.UPGRADE]) => { return render( - + { await Onyx.merge(ONYXKEYS.NETWORK, {isOffline: true}); await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, policy); await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, report); - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${reportID}`, { - pendingExpenseAction: CONST.EXPENSE_PENDING_ACTION.SUBMIT, - }); - - await Onyx.merge(ONYXKEYS.DERIVED.VISIBLE_REPORT_ACTIONS, { - [reportID]: { - [submittedAction.reportActionID]: true, - }, - }); await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, { [submittedAction.reportActionID]: submittedAction, }); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${reportID}`, { + pendingExpenseAction: CONST.EXPENSE_PENDING_ACTION.SUBMIT, + }); }); // When the LHNOptionsList is rendered @@ -267,18 +261,12 @@ describe('LHNOptionsList', () => { await Onyx.merge(ONYXKEYS.NETWORK, {isOffline: false}); await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, policy); await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, report); - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${reportID}`, { - pendingExpenseAction: CONST.EXPENSE_PENDING_ACTION.SUBMIT, - }); - - await Onyx.merge(ONYXKEYS.DERIVED.VISIBLE_REPORT_ACTIONS, { - [reportID]: { - [commentAction.reportActionID]: true, - }, - }); await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, { [commentAction.reportActionID]: commentAction, }); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${reportID}`, { + pendingExpenseAction: CONST.EXPENSE_PENDING_ACTION.SUBMIT, + }); }); // When the LHNOptionsList is rendered diff --git a/tests/unit/MoneyRequestReportTransactionListActiveTransactionIDsTest.tsx b/tests/unit/MoneyRequestReportTransactionListActiveTransactionIDsTest.tsx new file mode 100644 index 000000000000..f7832f601c0d --- /dev/null +++ b/tests/unit/MoneyRequestReportTransactionListActiveTransactionIDsTest.tsx @@ -0,0 +1,186 @@ +import {findFocusedRoute} from '@react-navigation/native'; +import {renderHook} from '@testing-library/react-native'; +import {useEffect} from 'react'; +import useDeepCompareRef from '@hooks/useDeepCompareRef'; +import {clearActiveTransactionIDs, setActiveTransactionIDs} from '@libs/actions/TransactionThreadNavigation'; +import {navigationRef} from '@libs/Navigation/Navigation'; +import SCREENS from '@src/SCREENS'; + +// Mock the TransactionThreadNavigation module +jest.mock('@libs/actions/TransactionThreadNavigation', () => ({ + setActiveTransactionIDs: jest.fn(() => Promise.resolve()), + clearActiveTransactionIDs: jest.fn(() => Promise.resolve()), +})); + +// Mock the navigation module +jest.mock('@libs/Navigation/Navigation', () => ({ + navigationRef: { + getRootState: jest.fn(), + }, +})); + +// Mock @react-navigation/native +jest.mock('@react-navigation/native', () => ({ + findFocusedRoute: jest.fn(), + useFocusEffect: jest.fn(), +})); + +/** + * This hook replicates the active transaction IDs logic from MoneyRequestReportTransactionList + * to allow isolated testing of the useEffect behavior. + */ +function useActiveTransactionIDsEffect(visualOrderTransactionIDs: string[]) { + const visualOrderTransactionIDsDeepCompare = useDeepCompareRef(visualOrderTransactionIDs); + + useEffect(() => { + const focusedRoute = findFocusedRoute(navigationRef.getRootState()); + if (focusedRoute?.name !== SCREENS.RIGHT_MODAL.SEARCH_REPORT) { + return; + } + setActiveTransactionIDs(visualOrderTransactionIDsDeepCompare ?? []); + return () => { + clearActiveTransactionIDs(); + }; + }, [visualOrderTransactionIDsDeepCompare]); + + return {visualOrderTransactionIDsDeepCompare}; +} + +describe('MoneyRequestReportTransactionList - Active Transaction IDs Effect', () => { + const mockSetActiveTransactionIDs = setActiveTransactionIDs as jest.MockedFunction; + const mockClearActiveTransactionIDs = clearActiveTransactionIDs as jest.MockedFunction; + const mockFindFocusedRoute = findFocusedRoute as jest.MockedFunction; + + beforeEach(() => { + jest.clearAllMocks(); + (navigationRef.getRootState as jest.Mock).mockReturnValue({} as ReturnType); + }); + + it('should call setActiveTransactionIDs when focused route is SEARCH_REPORT', () => { + // Given the focused route is SEARCH_REPORT + mockFindFocusedRoute.mockReturnValue({name: SCREENS.RIGHT_MODAL.SEARCH_REPORT, key: 'test-key'}); + + const transactionIDs = ['trans1', 'trans2', 'trans3']; + + // When the hook is rendered + renderHook(() => useActiveTransactionIDsEffect(transactionIDs)); + + // Then setActiveTransactionIDs should be called with the transaction IDs + expect(mockSetActiveTransactionIDs).toHaveBeenCalledWith(transactionIDs); + }); + + it('should NOT call setActiveTransactionIDs when focused route is NOT SEARCH_REPORT', () => { + // Given the focused route is something other than SEARCH_REPORT + mockFindFocusedRoute.mockReturnValue({name: 'SomeOtherRoute', key: 'test-key'}); + + const transactionIDs = ['trans1', 'trans2']; + + // When the hook is rendered + renderHook(() => useActiveTransactionIDsEffect(transactionIDs)); + + // Then setActiveTransactionIDs should NOT be called + expect(mockSetActiveTransactionIDs).not.toHaveBeenCalled(); + }); + + it('should NOT call setActiveTransactionIDs when focused route is undefined', () => { + // Given there is no focused route + mockFindFocusedRoute.mockReturnValue(undefined); + + const transactionIDs = ['trans1']; + + // When the hook is rendered + renderHook(() => useActiveTransactionIDsEffect(transactionIDs)); + + // Then setActiveTransactionIDs should NOT be called + expect(mockSetActiveTransactionIDs).not.toHaveBeenCalled(); + }); + + it('should call clearActiveTransactionIDs on unmount when route was SEARCH_REPORT', () => { + // Given the focused route is SEARCH_REPORT + mockFindFocusedRoute.mockReturnValue({name: SCREENS.RIGHT_MODAL.SEARCH_REPORT, key: 'test-key'}); + + const transactionIDs = ['trans1', 'trans2']; + + // When the hook is rendered and then unmounted + const {unmount} = renderHook(() => useActiveTransactionIDsEffect(transactionIDs)); + + expect(mockClearActiveTransactionIDs).not.toHaveBeenCalled(); + + unmount(); + + // Then clearActiveTransactionIDs should be called + expect(mockClearActiveTransactionIDs).toHaveBeenCalledTimes(1); + }); + + it('should NOT call clearActiveTransactionIDs on unmount when route was NOT SEARCH_REPORT', () => { + // Given the focused route is NOT SEARCH_REPORT + mockFindFocusedRoute.mockReturnValue({name: 'SomeOtherRoute', key: 'test-key'}); + + const transactionIDs = ['trans1']; + + // When the hook is rendered and then unmounted + const {unmount} = renderHook(() => useActiveTransactionIDsEffect(transactionIDs)); + + unmount(); + + // Then clearActiveTransactionIDs should NOT be called (since the effect returned early) + expect(mockClearActiveTransactionIDs).not.toHaveBeenCalled(); + }); + + it('should update active transaction IDs when the list changes (deep comparison)', () => { + // Given the focused route is SEARCH_REPORT + mockFindFocusedRoute.mockReturnValue({name: SCREENS.RIGHT_MODAL.SEARCH_REPORT, key: 'test-key'}); + + const initialTransactionIDs = ['trans1', 'trans2']; + + // When the hook is rendered + const {rerender} = renderHook(({ids}) => useActiveTransactionIDsEffect(ids), { + initialProps: {ids: initialTransactionIDs}, + }); + + expect(mockSetActiveTransactionIDs).toHaveBeenCalledTimes(1); + expect(mockSetActiveTransactionIDs).toHaveBeenLastCalledWith(initialTransactionIDs); + + // When the transaction IDs change + const newTransactionIDs = ['trans1', 'trans2', 'trans3']; + rerender({ids: newTransactionIDs}); + + // Then setActiveTransactionIDs should be called again with the new IDs + expect(mockSetActiveTransactionIDs).toHaveBeenCalledTimes(2); + expect(mockSetActiveTransactionIDs).toHaveBeenLastCalledWith(newTransactionIDs); + }); + + it('should NOT update when transaction IDs array has same content (deep comparison)', () => { + // Given the focused route is SEARCH_REPORT + mockFindFocusedRoute.mockReturnValue({name: SCREENS.RIGHT_MODAL.SEARCH_REPORT, key: 'test-key'}); + + const initialTransactionIDs = ['trans1', 'trans2']; + + // When the hook is rendered + const {rerender} = renderHook(({ids}) => useActiveTransactionIDsEffect(ids), { + initialProps: {ids: initialTransactionIDs}, + }); + + expect(mockSetActiveTransactionIDs).toHaveBeenCalledTimes(1); + + // When rerendering with a new array reference but same content + const sameContentNewArray = ['trans1', 'trans2']; + rerender({ids: sameContentNewArray}); + + // Then setActiveTransactionIDs should NOT be called again (deep comparison prevents it) + expect(mockSetActiveTransactionIDs).toHaveBeenCalledTimes(1); + }); + + it('should handle empty transaction IDs array', () => { + // Given the focused route is SEARCH_REPORT + mockFindFocusedRoute.mockReturnValue({name: SCREENS.RIGHT_MODAL.SEARCH_REPORT, key: 'test-key'}); + + const emptyTransactionIDs: string[] = []; + + // When the hook is rendered with empty array + renderHook(() => useActiveTransactionIDsEffect(emptyTransactionIDs)); + + // Then setActiveTransactionIDs should be called with empty array + expect(mockSetActiveTransactionIDs).toHaveBeenCalledWith([]); + }); +}); diff --git a/tests/unit/OptionsListUtilsTest.tsx b/tests/unit/OptionsListUtilsTest.tsx index 725011d14142..8a4b214320c6 100644 --- a/tests/unit/OptionsListUtilsTest.tsx +++ b/tests/unit/OptionsListUtilsTest.tsx @@ -21,6 +21,7 @@ import { filterWorkspaceChats, formatMemberForList, getCurrentUserSearchTerms, + getFilteredRecentAttendees, getLastActorDisplayName, getLastActorDisplayNameFromLastVisibleActions, getLastMessageTextForReport, @@ -2454,6 +2455,54 @@ describe('OptionsListUtils', () => { expect(filteredOptions.userToInvite).toBe(null); }); + it('should not return userToInvite for plain text name when shouldAcceptName is false', () => { + // Given a set of options + const options = getValidOptions( + {reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}, + {}, + {}, + nvpDismissedProductTraining, + loginList, + CURRENT_USER_ACCOUNT_ID, + CURRENT_USER_EMAIL, + { + includeUserToInvite: true, + }, + ); + + // When we call filterAndOrderOptions with a plain text name (not email or phone) without shouldAcceptName + const filteredOptions = filterAndOrderOptions(options, 'Jeff Amazon', COUNTRY_CODE, loginList, CURRENT_USER_EMAIL, CURRENT_USER_ACCOUNT_ID, { + shouldAcceptName: false, + }); + + // Then userToInvite should be null since plain names are not accepted by default + expect(filteredOptions?.userToInvite).toBe(null); + }); + + it('should return userToInvite for plain text name when shouldAcceptName is true', () => { + // Given a set of options + const options = getValidOptions( + {reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}, + {}, + {}, + nvpDismissedProductTraining, + loginList, + CURRENT_USER_ACCOUNT_ID, + CURRENT_USER_EMAIL, + { + includeUserToInvite: true, + }, + ); + + // When we call filterAndOrderOptions with a plain text name (not email or phone) with shouldAcceptName + const filteredOptions = filterAndOrderOptions(options, 'Jeff', COUNTRY_CODE, loginList, CURRENT_USER_EMAIL, CURRENT_USER_ACCOUNT_ID, { + shouldAcceptName: true, + }); + + // Then userToInvite should be returned for the plain name + expect(filteredOptions?.userToInvite?.text).toBe('Jeff'); + }); + it('should not return any options if search value does not match any personal details', () => { // Given a set of options const options = getValidOptions( @@ -3188,16 +3237,17 @@ describe('OptionsListUtils', () => { }); describe('getLastMessageTextForReport', () => { - describe('getReportPreviewMessage', () => { - it('should format report preview message correctly for non-policy expense chat with IOU action', async () => { + describe('REPORT_PREVIEW action', () => { + it('should show report preview message for non-policy expense chat', async () => { + const report: Report = { + ...createRandomReport(0, undefined), + isOwnPolicyExpenseChat: false, + }; const iouReport: Report = { ...createRandomReport(1, undefined), isOwnPolicyExpenseChat: false, type: CONST.REPORT.TYPE.IOU, isWaitingOnBankAccount: false, - currency: CONST.CURRENCY.USD, - total: 100, - unheldTotal: 100, }; const reportPreviewAction: ReportAction = { ...createRandomReportAction(1), @@ -3221,7 +3271,6 @@ describe('OptionsListUtils', () => { }; const iouAction: ReportAction = { ...createRandomReportAction(2), - reportID: iouReport.reportID, actionName: CONST.REPORT.ACTIONS.TYPE.IOU, message: [{type: 'COMMENT', text: ''}], originalMessage: { @@ -3230,15 +3279,24 @@ describe('OptionsListUtils', () => { }, shouldShow: true, }; - - await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${iouReport.reportID}`, iouReport); - await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, transaction); - await waitForBatchedUpdates(); - - // Test getReportPreviewMessage directly - this is the function responsible for formatting the message + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${iouReport.reportID}`, iouReport); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`, { + [reportPreviewAction.reportActionID]: reportPreviewAction, + }); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport.reportID}`, { + [iouAction.reportActionID]: iouAction, + }); + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, transaction); + const lastMessage = getLastMessageTextForReport({ + translate: translateLocal, + report, + lastActorDetails: null, + isReportArchived: false, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + }); const reportPreviewMessage = getReportPreviewMessage(iouReport, iouAction, true, false, null, true, reportPreviewAction); - const formattedMessage = formatReportLastMessageText(Parser.htmlToText(reportPreviewMessage)); - expect(formattedMessage).toBe('$1.00 for A A A'); + const expected = formatReportLastMessageText(Parser.htmlToText(reportPreviewMessage)); + expect(lastMessage).toBe(expected); }); }); it('MOVED_TRANSACTION action', async () => { @@ -3291,7 +3349,6 @@ describe('OptionsListUtils', () => { report, lastActorDetails: null, isReportArchived: false, - visibleReportActionsDataParam: {}, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, }); expect(lastMessage).toBe(Parser.htmlToText(translate(CONST.LOCALES.EN, 'iou.automaticallySubmitted'))); @@ -3317,7 +3374,6 @@ describe('OptionsListUtils', () => { report, lastActorDetails: null, isReportArchived: false, - visibleReportActionsDataParam: {}, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, }); expect(lastMessage).toBe(Parser.htmlToText(translate(CONST.LOCALES.EN, 'iou.automaticallyApproved'))); @@ -3343,7 +3399,6 @@ describe('OptionsListUtils', () => { report, lastActorDetails: null, isReportArchived: false, - visibleReportActionsDataParam: {}, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, }); expect(lastMessage).toBe(Parser.htmlToText(translate(CONST.LOCALES.EN, 'iou.automaticallyForwarded'))); @@ -3366,7 +3421,6 @@ describe('OptionsListUtils', () => { report, lastActorDetails: null, isReportArchived: false, - visibleReportActionsDataParam: {}, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, }); expect(lastMessage).toBe(Parser.htmlToText(translate(CONST.LOCALES.EN, 'workspaceActions.forcedCorporateUpgrade'))); @@ -3478,7 +3532,6 @@ describe('OptionsListUtils', () => { report, lastActorDetails: null, isReportArchived: false, - visibleReportActionsDataParam: {}, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, }); expect(result).toBe(expectedVisibleText); @@ -3530,7 +3583,6 @@ describe('OptionsListUtils', () => { isReportArchived: false, policy, reportMetadata, - visibleReportActionsDataParam: {}, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, }); expect(lastMessage).toBe(translate(CONST.LOCALES.EN, 'iou.queuedToSubmitViaDEW')); @@ -3563,7 +3615,6 @@ describe('OptionsListUtils', () => { report, lastActorDetails: null, isReportArchived: false, - visibleReportActionsDataParam: {}, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, }); expect(lastMessage).toBe(customErrorMessage); @@ -3593,7 +3644,6 @@ describe('OptionsListUtils', () => { report, lastActorDetails: null, isReportArchived: false, - visibleReportActionsDataParam: {}, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, }); expect(lastMessage).toBe(translate(CONST.LOCALES.EN, 'iou.error.genericCreateFailureMessage')); @@ -4965,4 +5015,52 @@ describe('OptionsListUtils', () => { expect(result?.login).toBe('Jeff Amazon'); }); }); + + describe('getFilteredRecentAttendees', () => { + it('should deduplicate recent attendees by email', () => { + const personalDetails = {}; + const attendees: Array<{email: string; displayName: string; avatarUrl: string}> = []; + const recentAttendees = [ + {email: 'user1@example.com', displayName: 'User One', avatarUrl: ''}, + {email: 'user1@example.com', displayName: 'User One Duplicate', avatarUrl: ''}, // Duplicate by email + {email: 'user2@example.com', displayName: 'User Two', avatarUrl: ''}, + ]; + + const result = getFilteredRecentAttendees(personalDetails, attendees, recentAttendees, CURRENT_USER_EMAIL, CURRENT_USER_ACCOUNT_ID); + + // Should deduplicate by email - user1@example.com should only appear once + const logins = result.map((r) => r.login); + const user1Count = logins.filter((login) => login === 'user1@example.com').length; + expect(user1Count).toBe(1); + }); + + it('should deduplicate name-only attendees by displayName', () => { + const personalDetails = {}; + const attendees: Array<{email: string; displayName: string; avatarUrl: string}> = []; + const recentAttendees = [ + {email: '', displayName: 'Name Only', avatarUrl: ''}, + {email: '', displayName: 'Name Only', avatarUrl: ''}, // Duplicate by displayName (name-only attendee) + {email: '', displayName: 'Another Name', avatarUrl: ''}, + ]; + + const result = getFilteredRecentAttendees(personalDetails, attendees, recentAttendees, CURRENT_USER_EMAIL, CURRENT_USER_ACCOUNT_ID); + + // Should deduplicate by displayName - Name Only should only appear once + const logins = result.map((r) => r.login); + const nameOnlyCount = logins.filter((login) => login === 'Name Only').length; + expect(nameOnlyCount).toBe(1); + }); + + it('should use displayName as login for name-only attendees', () => { + const personalDetails = {}; + const attendees: Array<{email: string; displayName: string; avatarUrl: string}> = []; + const recentAttendees = [{email: '', displayName: 'John Smith', avatarUrl: ''}]; + + const result = getFilteredRecentAttendees(personalDetails, attendees, recentAttendees, CURRENT_USER_EMAIL, CURRENT_USER_ACCOUNT_ID); + + // Name-only attendee should have displayName as login + const johnSmith = result.find((r) => r.login === 'John Smith'); + expect(johnSmith).toBeDefined(); + }); + }); }); diff --git a/tests/unit/QuickActionNavigationTest.ts b/tests/unit/QuickActionNavigationTest.ts index af64b1146740..6b379775d395 100644 --- a/tests/unit/QuickActionNavigationTest.ts +++ b/tests/unit/QuickActionNavigationTest.ts @@ -34,7 +34,7 @@ describe('IOU Utils', () => { }); // Then we should start manual submit request flow - expect(startMoneyRequest).toHaveBeenCalledWith(CONST.IOU.TYPE.SUBMIT, reportID, CONST.IOU.REQUEST_TYPE.MANUAL, true); + expect(startMoneyRequest).toHaveBeenCalledWith(CONST.IOU.TYPE.SUBMIT, reportID, CONST.IOU.REQUEST_TYPE.MANUAL, true, undefined, undefined, undefined); }); it('should be navigated to Scan receipt Split Expense', () => { @@ -50,7 +50,7 @@ describe('IOU Utils', () => { }); // Then we should start scan split request flow - expect(startMoneyRequest).toHaveBeenCalledWith(CONST.IOU.TYPE.SPLIT, reportID, CONST.IOU.REQUEST_TYPE.SCAN, true); + expect(startMoneyRequest).toHaveBeenCalledWith(CONST.IOU.TYPE.SPLIT, reportID, CONST.IOU.REQUEST_TYPE.SCAN, true, undefined, undefined, undefined); }); it('should be navigated to Track distance Expense', () => { @@ -66,7 +66,7 @@ describe('IOU Utils', () => { }); // Then we should start distance track request flow - expect(startDistanceRequest).toHaveBeenCalledWith(CONST.IOU.TYPE.TRACK, reportID, CONST.IOU.REQUEST_TYPE.DISTANCE_MAP, true); + expect(startDistanceRequest).toHaveBeenCalledWith(CONST.IOU.TYPE.TRACK, reportID, CONST.IOU.REQUEST_TYPE.DISTANCE_MAP, true, undefined, undefined); }); it('should be navigated to Map distance Expense by default', () => { @@ -82,7 +82,7 @@ describe('IOU Utils', () => { }); // Then we should start map distance request flow - expect(startDistanceRequest).toHaveBeenCalledWith(CONST.IOU.TYPE.SUBMIT, reportID, CONST.IOU.REQUEST_TYPE.DISTANCE_MAP, true); + expect(startDistanceRequest).toHaveBeenCalledWith(CONST.IOU.TYPE.SUBMIT, reportID, CONST.IOU.REQUEST_TYPE.DISTANCE_MAP, true, undefined, undefined); }); it('should be navigated to request distance Expense depending on lastDistanceExpenseType', () => { @@ -99,7 +99,7 @@ describe('IOU Utils', () => { }); // Then we should start manual distance request flow - expect(startDistanceRequest).toHaveBeenCalledWith(CONST.IOU.TYPE.SUBMIT, reportID, CONST.IOU.REQUEST_TYPE.DISTANCE_MANUAL, true); + expect(startDistanceRequest).toHaveBeenCalledWith(CONST.IOU.TYPE.SUBMIT, reportID, CONST.IOU.REQUEST_TYPE.DISTANCE_MANUAL, true, undefined, undefined); }); it('should be navigated to Per Diem Expense', () => { @@ -115,7 +115,7 @@ describe('IOU Utils', () => { }); // Then we should start per diem request flow - expect(startMoneyRequest).toHaveBeenCalledWith(CONST.IOU.TYPE.SUBMIT, reportID, CONST.IOU.REQUEST_TYPE.PER_DIEM, true); + expect(startMoneyRequest).toHaveBeenCalledWith(CONST.IOU.TYPE.SUBMIT, reportID, CONST.IOU.REQUEST_TYPE.PER_DIEM, true, undefined, undefined, undefined); }); }); }); diff --git a/tests/unit/ReportActionsUtilsTest.ts b/tests/unit/ReportActionsUtilsTest.ts index 35ce577d3bb7..24a06868b876 100644 --- a/tests/unit/ReportActionsUtilsTest.ts +++ b/tests/unit/ReportActionsUtilsTest.ts @@ -580,7 +580,6 @@ describe('ReportActionsUtils', () => { { created: '2022-11-13 22:27:01.825', reportActionID: '8401445780099176', - reportID: '1', actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, originalMessage: { html: 'Hello world', @@ -597,7 +596,6 @@ describe('ReportActionsUtils', () => { { created: '2022-11-12 22:27:01.825', reportActionID: '6401435781022176', - reportID: '1', actionName: CONST.REPORT.ACTIONS.TYPE.CREATED, originalMessage: { html: 'Hello world', @@ -614,7 +612,6 @@ describe('ReportActionsUtils', () => { { created: '2022-11-11 22:27:01.825', reportActionID: '2962390724708756', - reportID: '1', actionName: CONST.REPORT.ACTIONS.TYPE.IOU, originalMessage: { amount: 0, @@ -632,7 +629,6 @@ describe('ReportActionsUtils', () => { { created: '2022-11-10 22:27:01.825', reportActionID: '1609646094152486', - reportID: '1', actionName: CONST.REPORT.ACTIONS.TYPE.RENAMED, originalMessage: { html: 'Hello world', @@ -651,7 +647,6 @@ describe('ReportActionsUtils', () => { { created: '2022-11-09 22:27:01.825', reportActionID: '8049485084562457', - reportID: '1', actionName: CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_FIELD, originalMessage: {}, message: [{html: 'updated the Approval Mode from "Submit and Approve" to "Submit and Close"', type: 'Action type', text: 'Action text'}], @@ -659,7 +654,6 @@ describe('ReportActionsUtils', () => { { created: '2022-11-08 22:27:06.825', reportActionID: '1661970171066216', - reportID: '1', actionName: CONST.REPORT.ACTIONS.TYPE.REIMBURSEMENT_QUEUED, originalMessage: { paymentType: 'ACH', @@ -669,7 +663,6 @@ describe('ReportActionsUtils', () => { { created: '2022-11-06 22:27:08.825', reportActionID: '1661970171066220', - reportID: '1', actionName: CONST.REPORT.ACTIONS.TYPE.TASK_EDITED, originalMessage: { html: 'Hello world', @@ -691,7 +684,6 @@ describe('ReportActionsUtils', () => { const movedTransactionAction: ReportAction = { created: '2022-11-13 22:27:01.825', reportActionID: '8401445780099177', - reportID: '1', actionName: CONST.REPORT.ACTIONS.TYPE.MOVED_TRANSACTION, originalMessage: { fromReportID: CONST.REPORT.UNREPORTED_REPORT_ID, @@ -702,7 +694,6 @@ describe('ReportActionsUtils', () => { const addCommentAction: ReportAction = { created: '2022-11-12 22:27:01.825', reportActionID: '6401435781022176', - reportID: '1', actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, originalMessage: { html: 'Hello world', @@ -728,7 +719,6 @@ describe('ReportActionsUtils', () => { { created: '2022-11-13 22:27:01.825', reportActionID: '8401445780099176', - reportID: '1', actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, originalMessage: { html: 'Hello world', @@ -745,7 +735,6 @@ describe('ReportActionsUtils', () => { { created: '2022-11-12 22:27:01.825', reportActionID: '6401435781022176', - reportID: '1', actionName: CONST.REPORT.ACTIONS.TYPE.CREATED, originalMessage: { html: 'Hello world', @@ -762,7 +751,6 @@ describe('ReportActionsUtils', () => { { created: '2022-11-11 22:27:01.825', reportActionID: '2962390724708756', - reportID: '1', actionName: CONST.REPORT.ACTIONS.TYPE.IOU, originalMessage: { amount: 0, @@ -780,7 +768,6 @@ describe('ReportActionsUtils', () => { { created: '2022-11-10 22:27:01.825', reportActionID: '1609646094152486', - reportID: '1', actionName: CONST.REPORT.ACTIONS.TYPE.RENAMED, originalMessage: { html: 'Hello world', @@ -799,7 +786,6 @@ describe('ReportActionsUtils', () => { { created: '2022-11-09 22:27:01.825', reportActionID: '1661970171066218', - reportID: '1', actionName: CONST.REPORT.ACTIONS.TYPE.CLOSED, originalMessage: { policyName: 'default', // change to const @@ -828,7 +814,6 @@ describe('ReportActionsUtils', () => { { created: '2022-11-13 22:27:01.825', reportActionID: '8401445780099176', - reportID: '1', actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, originalMessage: { html: 'Hello world', @@ -845,7 +830,6 @@ describe('ReportActionsUtils', () => { { created: '2022-11-12 22:27:01.825', reportActionID: '8401445780099175', - reportID: '1', actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, originalMessage: { html: 'Hello world', @@ -857,7 +841,6 @@ describe('ReportActionsUtils', () => { { created: '2022-11-11 22:27:01.825', reportActionID: '8401445780099174', - reportID: '1', actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, originalMessage: { html: 'Hello world', @@ -880,7 +863,6 @@ describe('ReportActionsUtils', () => { { created: '2024-11-19 08:04:13.728', reportActionID: '1607371725956675966', - reportID: '1', actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, originalMessage: { html: '', @@ -900,7 +882,6 @@ describe('ReportActionsUtils', () => { { created: '2024-11-19 08:00:14.352', reportActionID: '4655978522337302598', - reportID: '1', actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, originalMessage: { html: '#join', @@ -919,7 +900,6 @@ describe('ReportActionsUtils', () => { { created: '2022-11-09 22:27:01.825', reportActionID: '8049485084562457', - reportID: '1', actionName: CONST.REPORT.ACTIONS.TYPE.ACTIONABLE_REPORT_MENTION_WHISPER, originalMessage: { lastModified: '2024-11-19 08:00:14.353', @@ -937,7 +917,6 @@ describe('ReportActionsUtils', () => { { created: '2022-11-12 22:27:01.825', reportActionID: '6401435781022176', - reportID: '1', actionName: CONST.REPORT.ACTIONS.TYPE.ACTIONABLE_MENTION_WHISPER, originalMessage: { inviteeAccountIDs: [18414674], @@ -1074,7 +1053,6 @@ describe('ReportActionsUtils', () => { ...LHNTestUtils.getFakeReportAction('email1@test.com', 3), created: '2023-08-01 16:00:00', reportActionID: 'action1', - reportID: '1', actionName: 'ADDCOMMENT', originalMessage: { html: 'Hello world', @@ -1085,7 +1063,6 @@ describe('ReportActionsUtils', () => { ...LHNTestUtils.getFakeReportAction('email2@test.com', 3), created: '2023-08-01 18:00:00', reportActionID: 'action2', - reportID: '1', actionName: 'ADDCOMMENT', originalMessage: { html: 'Hello world', diff --git a/tests/unit/ReportUtilsTest.ts b/tests/unit/ReportUtilsTest.ts index 400f3dc24e65..59b81f0512fa 100644 --- a/tests/unit/ReportUtilsTest.ts +++ b/tests/unit/ReportUtilsTest.ts @@ -80,6 +80,7 @@ import { getReasonAndReportActionThatRequiresAttention, getReportIDFromLink, getReportName as getReportNameDeprecated, + getReportNotificationPreference, getReportOrDraftReport, getReportPreviewMessage, getReportStatusTranslation, @@ -145,7 +146,7 @@ import type { import type {ErrorFields, Errors, OnyxValueWithOfflineFeedback} from '@src/types/onyx/OnyxCommon'; import type {JoinWorkspaceResolution} from '@src/types/onyx/OriginalMessage'; import type {ACHAccount, PolicyReportField} from '@src/types/onyx/Policy'; -import type {Participant, Participants} from '@src/types/onyx/Report'; +import type {NotificationPreference, Participant, Participants} from '@src/types/onyx/Report'; import {toCollectionDataSet} from '@src/types/utils/CollectionDataSet'; import {actionR14932 as mockIOUAction} from '../../__mocks__/reportData/actions'; import {chatReportR14932 as mockedChatReport, iouReportR14932 as mockIOUReport} from '../../__mocks__/reportData/reports'; @@ -10918,6 +10919,18 @@ describe('ReportUtils', () => { }); }); + describe('getReportNotificationPreference', () => { + it('should return hidden if notification preference is empty', () => { + const report: Report = { + ...LHNTestUtils.getFakeReport([currentUserAccountID]), + participants: { + [currentUserAccountID]: {notificationPreference: '' as NotificationPreference}, + }, + }; + expect(getReportNotificationPreference(report)).toBe(CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN); + }); + }); + describe('getAllReportActionsErrorsAndReportActionThatRequiresAttention for DEW', () => { it('should return error for DEW_SUBMIT_FAILED action on OPEN report', () => { const report = { diff --git a/tests/unit/Search/SearchQueryUtilsTest.ts b/tests/unit/Search/SearchQueryUtilsTest.ts index 4e3627a5c650..bd010f505426 100644 --- a/tests/unit/Search/SearchQueryUtilsTest.ts +++ b/tests/unit/Search/SearchQueryUtilsTest.ts @@ -637,6 +637,35 @@ describe('SearchQueryUtils', () => { }); }); + test('attendee filter preserves name-only attendees without filtering by personalDetails', () => { + const policyCategories = {}; + const policyTags = {}; + const currencyList = {}; + const personalDetails = { + 12345: {accountID: 12345, login: 'user@example.com'}, + }; + const cardList = {}; + const reports = {}; + const taxRates = {}; + + // Test with mix of accountID and name-only attendee + const queryString = 'sortBy:date sortOrder:desc type:expense attendee:12345,ZZ'; + const queryJSON = buildSearchQueryJSON(queryString); + + if (!queryJSON) { + throw new Error('Failed to parse query string'); + } + + const result = buildFilterFormValuesFromQuery(queryJSON, policyCategories, policyTags, currencyList, personalDetails, cardList, reports, taxRates); + + // Both values should be preserved - name-only attendees should not be filtered out + expect(result).toEqual({ + type: 'expense', + status: CONST.SEARCH.STATUS.EXPENSE.ALL, + attendee: ['12345', 'ZZ'], + }); + }); + describe('view parameter', () => { const emptyParams = { policyCategories: {}, diff --git a/tests/unit/SearchParserTest.ts b/tests/unit/SearchParserTest.ts index 8aa73a26fe5e..80ff7c6a4af1 100644 --- a/tests/unit/SearchParserTest.ts +++ b/tests/unit/SearchParserTest.ts @@ -544,6 +544,126 @@ const tests = [ }, }, }, + { + query: 'type:expense group-by:from', + expected: { + type: CONST.SEARCH.DATA_TYPES.EXPENSE, + status: CONST.SEARCH.STATUS.EXPENSE.ALL, + sortBy: CONST.SEARCH.TABLE_COLUMNS.GROUP_FROM, + sortOrder: CONST.SEARCH.SORT_ORDER.ASC, + view: 'table', + groupBy: CONST.SEARCH.GROUP_BY.FROM, + filters: null, + }, + }, + { + query: 'type:expense group-by:card', + expected: { + type: CONST.SEARCH.DATA_TYPES.EXPENSE, + status: CONST.SEARCH.STATUS.EXPENSE.ALL, + sortBy: CONST.SEARCH.TABLE_COLUMNS.GROUP_CARD, + sortOrder: CONST.SEARCH.SORT_ORDER.ASC, + view: 'table', + groupBy: CONST.SEARCH.GROUP_BY.CARD, + filters: null, + }, + }, + { + query: 'type:expense group-by:withdrawal-id', + expected: { + type: CONST.SEARCH.DATA_TYPES.EXPENSE, + status: CONST.SEARCH.STATUS.EXPENSE.ALL, + sortBy: CONST.SEARCH.TABLE_COLUMNS.GROUP_WITHDRAWN, + sortOrder: CONST.SEARCH.SORT_ORDER.DESC, + view: 'table', + groupBy: CONST.SEARCH.GROUP_BY.WITHDRAWAL_ID, + filters: null, + }, + }, + { + query: 'type:expense group-by:category', + expected: { + type: CONST.SEARCH.DATA_TYPES.EXPENSE, + status: CONST.SEARCH.STATUS.EXPENSE.ALL, + sortBy: CONST.SEARCH.TABLE_COLUMNS.GROUP_CATEGORY, + sortOrder: CONST.SEARCH.SORT_ORDER.ASC, + view: 'table', + groupBy: CONST.SEARCH.GROUP_BY.CATEGORY, + filters: null, + }, + }, + { + query: 'type:expense group-by:tag', + expected: { + type: CONST.SEARCH.DATA_TYPES.EXPENSE, + status: CONST.SEARCH.STATUS.EXPENSE.ALL, + sortBy: CONST.SEARCH.TABLE_COLUMNS.GROUP_TAG, + sortOrder: CONST.SEARCH.SORT_ORDER.ASC, + view: 'table', + groupBy: CONST.SEARCH.GROUP_BY.TAG, + filters: null, + }, + }, + { + query: 'type:expense group-by:merchant', + expected: { + type: CONST.SEARCH.DATA_TYPES.EXPENSE, + status: CONST.SEARCH.STATUS.EXPENSE.ALL, + sortBy: CONST.SEARCH.TABLE_COLUMNS.GROUP_MERCHANT, + sortOrder: CONST.SEARCH.SORT_ORDER.ASC, + view: 'table', + groupBy: CONST.SEARCH.GROUP_BY.MERCHANT, + filters: null, + }, + }, + { + query: 'type:expense group-by:month', + expected: { + type: CONST.SEARCH.DATA_TYPES.EXPENSE, + status: CONST.SEARCH.STATUS.EXPENSE.ALL, + sortBy: CONST.SEARCH.TABLE_COLUMNS.GROUP_MONTH, + sortOrder: CONST.SEARCH.SORT_ORDER.DESC, + view: 'table', + groupBy: CONST.SEARCH.GROUP_BY.MONTH, + filters: null, + }, + }, + { + query: 'type:expense group-by:week', + expected: { + type: CONST.SEARCH.DATA_TYPES.EXPENSE, + status: CONST.SEARCH.STATUS.EXPENSE.ALL, + sortBy: CONST.SEARCH.TABLE_COLUMNS.GROUP_WEEK, + sortOrder: CONST.SEARCH.SORT_ORDER.DESC, + view: 'table', + groupBy: CONST.SEARCH.GROUP_BY.WEEK, + filters: null, + }, + }, + { + query: 'type:expense group-by:year', + expected: { + type: CONST.SEARCH.DATA_TYPES.EXPENSE, + status: CONST.SEARCH.STATUS.EXPENSE.ALL, + sortBy: CONST.SEARCH.TABLE_COLUMNS.GROUP_YEAR, + sortOrder: CONST.SEARCH.SORT_ORDER.DESC, + view: 'table', + groupBy: CONST.SEARCH.GROUP_BY.YEAR, + filters: null, + }, + }, + { + query: 'type:expense group-by:quarter', + expected: { + type: CONST.SEARCH.DATA_TYPES.EXPENSE, + status: CONST.SEARCH.STATUS.EXPENSE.ALL, + sortBy: CONST.SEARCH.TABLE_COLUMNS.GROUP_QUARTER, + sortOrder: CONST.SEARCH.SORT_ORDER.DESC, + view: 'table', + groupBy: CONST.SEARCH.GROUP_BY.QUARTER, + filters: null, + }, + }, { query: 'type:chat is:read', expected: { @@ -815,6 +935,44 @@ const keywordTests = [ }, }, }, + { + query: 'columns:per-diem,drafts,draft,tax-rate,policy-name,withdrawal-id,bank-account', + expected: { + type: 'expense', + status: CONST.SEARCH.STATUS.EXPENSE.ALL, + sortBy: 'date', + sortOrder: 'desc', + view: 'table', + columns: [ + 'perDiem', + 'drafts', + 'drafts', + CONST.SEARCH.TABLE_COLUMNS.TAX_RATE, + CONST.SEARCH.TABLE_COLUMNS.POLICY_NAME, + CONST.SEARCH.TABLE_COLUMNS.WITHDRAWAL_ID, + CONST.SEARCH.TABLE_COLUMNS.BANK_ACCOUNT, + ], + filters: null, + }, + }, + { + query: 'columns:long-report-id,exported-to,exchange-rate,reimbursable-total,non-reimbursable-total', + expected: { + type: 'expense', + status: CONST.SEARCH.STATUS.EXPENSE.ALL, + sortBy: 'date', + sortOrder: 'desc', + view: 'table', + columns: [ + CONST.SEARCH.TABLE_COLUMNS.REPORT_ID, + CONST.SEARCH.TABLE_COLUMNS.EXPORTED_TO, + CONST.SEARCH.TABLE_COLUMNS.EXCHANGE_RATE, + CONST.SEARCH.TABLE_COLUMNS.REIMBURSABLE_TOTAL, + CONST.SEARCH.TABLE_COLUMNS.NON_REIMBURSABLE_TOTAL, + ], + filters: null, + }, + }, { query: 'columns:original-amount,tax,report-id', expected: { @@ -823,7 +981,48 @@ const keywordTests = [ sortBy: 'date', sortOrder: 'desc', view: 'table', - columns: ['originalamount', 'taxAmount', 'base62ReportID'], + columns: [CONST.SEARCH.TABLE_COLUMNS.ORIGINAL_AMOUNT, CONST.SEARCH.TABLE_COLUMNS.TAX_AMOUNT, CONST.SEARCH.TABLE_COLUMNS.BASE_62_REPORT_ID], + filters: null, + }, + }, + { + query: 'columns:group-from,group-expenses,group-total,group-card,group-feed,group-bank-account,group-withdrawn,group-withdrawal-id', + expected: { + type: 'expense', + status: CONST.SEARCH.STATUS.EXPENSE.ALL, + sortBy: 'date', + sortOrder: 'desc', + view: 'table', + columns: [ + CONST.SEARCH.TABLE_COLUMNS.GROUP_FROM, + CONST.SEARCH.TABLE_COLUMNS.GROUP_EXPENSES, + CONST.SEARCH.TABLE_COLUMNS.GROUP_TOTAL, + CONST.SEARCH.TABLE_COLUMNS.GROUP_CARD, + CONST.SEARCH.TABLE_COLUMNS.GROUP_FEED, + CONST.SEARCH.TABLE_COLUMNS.GROUP_BANK_ACCOUNT, + CONST.SEARCH.TABLE_COLUMNS.GROUP_WITHDRAWN, + CONST.SEARCH.TABLE_COLUMNS.GROUP_WITHDRAWAL_ID, + ], + filters: null, + }, + }, + { + query: 'columns:group-category,group-tag,group-merchant,group-month,group-week,group-year,group-quarter', + expected: { + type: 'expense', + status: CONST.SEARCH.STATUS.EXPENSE.ALL, + sortBy: 'date', + sortOrder: 'desc', + view: 'table', + columns: [ + CONST.SEARCH.TABLE_COLUMNS.GROUP_CATEGORY, + CONST.SEARCH.TABLE_COLUMNS.GROUP_TAG, + CONST.SEARCH.TABLE_COLUMNS.GROUP_MERCHANT, + CONST.SEARCH.TABLE_COLUMNS.GROUP_MONTH, + CONST.SEARCH.TABLE_COLUMNS.GROUP_WEEK, + CONST.SEARCH.TABLE_COLUMNS.GROUP_YEAR, + CONST.SEARCH.TABLE_COLUMNS.GROUP_QUARTER, + ], filters: null, }, }, diff --git a/tests/unit/SidebarUtilsTest.ts b/tests/unit/SidebarUtilsTest.ts index 47e4c6d0aba3..3a7e7eb1a984 100644 --- a/tests/unit/SidebarUtilsTest.ts +++ b/tests/unit/SidebarUtilsTest.ts @@ -1816,7 +1816,6 @@ describe('SidebarUtils', () => { const lastAction: ReportAction = { ...createRandomReportAction(1), - reportID: iouReportR14932.reportID, message: [ { type: 'COMMENT', @@ -1840,16 +1839,8 @@ describe('SidebarUtils', () => { await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${iouReportR14932.reportID}`, iouReportR14932); await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${chatReportR14932.reportID}`, chatReportR14932); await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`, report); - await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReportR14932.reportID}`, { - [linkedCreateAction.reportActionID]: linkedCreateAction, - [lastAction.reportActionID]: lastAction, - }); - await Onyx.set(ONYXKEYS.DERIVED.VISIBLE_REPORT_ACTIONS, { - [iouReportR14932.reportID]: { - [linkedCreateAction.reportActionID]: true, - [lastAction.reportActionID]: true, - }, - }); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`, {[lastAction.reportActionID]: lastAction}); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReportR14932.reportID}`, {[linkedCreateAction.reportActionID]: linkedCreateAction}); }); const result = SidebarUtils.getOptionData({ @@ -1867,12 +1858,6 @@ describe('SidebarUtils', () => { lastActionReport: undefined, isReportArchived: undefined, currentUserAccountID: session.accountID, - visibleReportActionsData: { - [iouReportR14932.reportID]: { - [linkedCreateAction.reportActionID]: true, - [lastAction.reportActionID]: true, - }, - }, }); expect(result?.alternateText).toBe(`You: ${getReportActionMessageText(lastAction)}`); @@ -1893,7 +1878,6 @@ describe('SidebarUtils', () => { }; const lastAction: ReportAction = { ...createRandomReportAction(1), - reportID: '1', message: [ { type: 'COMMENT', @@ -1913,7 +1897,6 @@ describe('SidebarUtils', () => { }; const deletedAction: ReportAction = { ...createRandomReportAction(2), - reportID: '1', actionName: 'IOU', actorAccountID: 20337430, automatic: false, @@ -1993,7 +1976,6 @@ describe('SidebarUtils', () => { lastAction, lastActionReport: undefined, isReportArchived: undefined, - lastMessageTextFromReport: 'test action', currentUserAccountID: 0, }); diff --git a/tests/unit/SplitExpenseAutoAdjustmentTest.ts b/tests/unit/SplitExpenseAutoAdjustmentTest.ts new file mode 100644 index 000000000000..257d186a4fb6 --- /dev/null +++ b/tests/unit/SplitExpenseAutoAdjustmentTest.ts @@ -0,0 +1,257 @@ +import Onyx from 'react-native-onyx'; +import type {OnyxEntry} from 'react-native-onyx'; +import {addSplitExpenseField, evenlyDistributeSplitExpenseAmounts, updateSplitExpenseAmountField} from '@libs/actions/IOU'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Transaction} from '@src/types/onyx'; +import type {SplitExpense} from '@src/types/onyx/IOU'; +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; + +/** + * Tests for the split expense auto-adjustment feature. + * When splitting an expense: + * - Unedited splits auto-adjust to sum to 100%/total amount + * - Manually edited splits are "locked" and preserved + * - Adding a new split redistributes among unedited splits + */ +describe('Split Expense Auto-Adjustment', () => { + const ORIGINAL_TRANSACTION_ID = 'originalTx123'; + const REPORT_ID = 'report123'; + const CURRENCY = 'USD'; + const TOTAL_AMOUNT = 1000; // $10.00 in cents + + // Helper to create a mock draft transaction + const createMockDraftTransaction = (splitExpenses: SplitExpense[], amount = TOTAL_AMOUNT): Transaction => + ({ + transactionID: ORIGINAL_TRANSACTION_ID, + reportID: REPORT_ID, + amount, + currency: CURRENCY, + comment: { + originalTransactionID: ORIGINAL_TRANSACTION_ID, + splitExpenses, + }, + }) as unknown as Transaction; + + // Helper to create a split expense + const createSplitExpense = (transactionID: string, amount: number, isManuallyEdited = false): SplitExpense => ({ + transactionID, + amount, + created: '2024-01-01', + isManuallyEdited, + }); + + beforeAll(() => { + Onyx.init({ + keys: ONYXKEYS, + }); + }); + + beforeEach(() => { + return Onyx.clear().then(waitForBatchedUpdates); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('addSplitExpenseField', () => { + it('should redistribute evenly when adding a split to 2 unedited splits', async () => { + // Setup: 2 splits at $5/$5 (50/50) + const initialSplits = [createSplitExpense('split1', 500, false), createSplitExpense('split2', 500, false)]; + + const mockTransaction = createMockDraftTransaction(initialSplits); + + await Onyx.set(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${ORIGINAL_TRANSACTION_ID}`, mockTransaction); + await waitForBatchedUpdates(); + + // Action: Add a third split + addSplitExpenseField(mockTransaction, mockTransaction); + await waitForBatchedUpdates(); + + // Verify: Should be 3 splits at ~$3.33/$3.33/$3.34 (33/33/34%) + const draftTransaction = await new Promise>((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${ORIGINAL_TRANSACTION_ID}`, + callback: (value) => { + Onyx.disconnect(connection); + resolve(value); + }, + }); + }); + + const splitExpenses = draftTransaction?.comment?.splitExpenses ?? []; + expect(splitExpenses.length).toBe(3); + + // Total should equal original amount + const totalAmount = splitExpenses.reduce((sum, split) => sum + split.amount, 0); + expect(totalAmount).toBe(TOTAL_AMOUNT); + + // All splits should be unedited + expect(splitExpenses.every((split) => !split.isManuallyEdited)).toBe(true); + }); + + it('should preserve edited splits when adding a new split', async () => { + // Setup: 2 splits - one edited at $3, one unedited at $7 + const initialSplits = [ + createSplitExpense('split1', 300, true), // Edited/locked + createSplitExpense('split2', 700, false), // Unedited + ]; + + const mockTransaction = createMockDraftTransaction(initialSplits); + + await Onyx.set(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${ORIGINAL_TRANSACTION_ID}`, mockTransaction); + await waitForBatchedUpdates(); + + // Action: Add a third split + addSplitExpenseField(mockTransaction, mockTransaction); + await waitForBatchedUpdates(); + + // Verify + const draftTransaction = await new Promise>((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${ORIGINAL_TRANSACTION_ID}`, + callback: (value) => { + Onyx.disconnect(connection); + resolve(value); + }, + }); + }); + + const splitExpenses = draftTransaction?.comment?.splitExpenses ?? []; + expect(splitExpenses.length).toBe(3); + + // Edited split should remain at $3 and locked + const editedSplit = splitExpenses.find((s) => s.transactionID === 'split1'); + expect(editedSplit?.amount).toBe(300); + expect(editedSplit?.isManuallyEdited).toBe(true); + + // Remaining $7 should be split between 2 unedited splits + const uneditedSplits = splitExpenses.filter((s) => !s.isManuallyEdited); + expect(uneditedSplits.length).toBe(2); + const uneditedTotal = uneditedSplits.reduce((sum, s) => sum + s.amount, 0); + expect(uneditedTotal).toBe(700); // $7 total + + // Total should equal original amount + const totalAmount = splitExpenses.reduce((sum, split) => sum + split.amount, 0); + expect(totalAmount).toBe(TOTAL_AMOUNT); + }); + }); + + describe('updateSplitExpenseAmountField', () => { + it('should mark edited split and redistribute remaining to unedited splits', async () => { + // Setup: 3 unedited splits at $3.33/$3.33/$3.34 + const initialSplits = [createSplitExpense('split1', 333, false), createSplitExpense('split2', 333, false), createSplitExpense('split3', 334, false)]; + + const mockTransaction = createMockDraftTransaction(initialSplits); + + await Onyx.set(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${ORIGINAL_TRANSACTION_ID}`, mockTransaction); + await waitForBatchedUpdates(); + + // Action: Edit split1 to $3.00 + updateSplitExpenseAmountField(mockTransaction, 'split1', 300); + await waitForBatchedUpdates(); + + // Verify + const draftTransaction = await new Promise>((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${ORIGINAL_TRANSACTION_ID}`, + callback: (value) => { + Onyx.disconnect(connection); + resolve(value); + }, + }); + }); + + const splitExpenses = draftTransaction?.comment?.splitExpenses ?? []; + + // Edited split should be locked at $3 + const editedSplit = splitExpenses.find((s) => s.transactionID === 'split1'); + expect(editedSplit?.amount).toBe(300); + expect(editedSplit?.isManuallyEdited).toBe(true); + + // Remaining $7 should be split between 2 unedited splits + const uneditedSplits = splitExpenses.filter((s) => !s.isManuallyEdited); + expect(uneditedSplits.length).toBe(2); + const uneditedTotal = uneditedSplits.reduce((sum, s) => sum + s.amount, 0); + expect(uneditedTotal).toBe(700); + + // Total should equal original amount + const totalAmount = splitExpenses.reduce((sum, split) => sum + split.amount, 0); + expect(totalAmount).toBe(TOTAL_AMOUNT); + }); + + it('should not redistribute when all splits are manually edited', async () => { + // Setup: 2 manually edited splits + const initialSplits = [createSplitExpense('split1', 400, true), createSplitExpense('split2', 600, true)]; + + const mockTransaction = createMockDraftTransaction(initialSplits); + + await Onyx.set(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${ORIGINAL_TRANSACTION_ID}`, mockTransaction); + await waitForBatchedUpdates(); + + // Action: Edit split1 to $5.00 + updateSplitExpenseAmountField(mockTransaction, 'split1', 500); + await waitForBatchedUpdates(); + + // Verify: split2 should remain unchanged + const draftTransaction = await new Promise>((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${ORIGINAL_TRANSACTION_ID}`, + callback: (value) => { + Onyx.disconnect(connection); + resolve(value); + }, + }); + }); + + const splitExpenses = draftTransaction?.comment?.splitExpenses ?? []; + + expect(splitExpenses.find((s) => s.transactionID === 'split1')?.amount).toBe(500); + expect(splitExpenses.find((s) => s.transactionID === 'split2')?.amount).toBe(600); + + // Note: Total now exceeds original amount (user error case) + const totalAmount = splitExpenses.reduce((sum, split) => sum + split.amount, 0); + expect(totalAmount).toBe(1100); + }); + }); + + describe('evenlyDistributeSplitExpenseAmounts', () => { + it('should reset isManuallyEdited and distribute evenly', async () => { + // Setup: 3 splits with some manually edited + const initialSplits = [createSplitExpense('split1', 300, true), createSplitExpense('split2', 400, true), createSplitExpense('split3', 300, false)]; + + const mockTransaction = createMockDraftTransaction(initialSplits); + + await Onyx.set(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${ORIGINAL_TRANSACTION_ID}`, mockTransaction); + await waitForBatchedUpdates(); + + // Action: Make splits even + evenlyDistributeSplitExpenseAmounts(mockTransaction); + await waitForBatchedUpdates(); + + // Verify + const draftTransaction = await new Promise>((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${ORIGINAL_TRANSACTION_ID}`, + callback: (value) => { + Onyx.disconnect(connection); + resolve(value); + }, + }); + }); + + const splitExpenses = draftTransaction?.comment?.splitExpenses ?? []; + + // All splits should now be unedited + expect(splitExpenses.every((split) => !split.isManuallyEdited)).toBe(true); + + // Total should equal original amount + const totalAmount = splitExpenses.reduce((sum, split) => sum + split.amount, 0); + expect(totalAmount).toBe(TOTAL_AMOUNT); + + // Should be distributed as $3.33/$3.33/$3.34 + const amounts = splitExpenses.map((s) => s.amount).sort((a, b) => a - b); + expect(amounts).toEqual([333, 333, 334]); + }); + }); +}); diff --git a/tests/unit/ViolationUtilsTest.ts b/tests/unit/ViolationUtilsTest.ts index ceae31bd5645..6db5b7497249 100644 --- a/tests/unit/ViolationUtilsTest.ts +++ b/tests/unit/ViolationUtilsTest.ts @@ -362,6 +362,7 @@ describe('getViolationsOnyxData', () => { }); it('should add category specific violations', () => { + policy.areRulesEnabled = true; const result = ViolationsUtils.getViolationsOnyxData(transaction, transactionViolations, policy, policyTags, policyCategories, false, false); expect(result.value).toEqual(expect.arrayContaining([categoryOverLimitViolation, categoryReceiptRequiredViolation, categoryMissingCommentViolation, ...transactionViolations])); }); @@ -395,6 +396,83 @@ describe('getViolationsOnyxData', () => { const itemizedReceiptViolation = violations.find((v: TransactionViolation) => v.name === CONST.VIOLATIONS.ITEMIZED_RECEIPT_REQUIRED); expect(itemizedReceiptViolation).toBeDefined(); // Should follow policy threshold }); + + it('should add receiptRequired when itemizedReceiptRequired existed but category changed to never require itemized', () => { + // Given a transaction that previously had an itemizedReceiptRequired violation because the policy requires itemized receipts + policy.maxExpenseAmountNoReceipt = 100; // $1.00 + policy.maxExpenseAmountNoItemizedReceipt = 100; // $1.00 + policyCategories.Food.maxAmountNoReceipt = undefined; + policyCategories.Food.maxAmountNoItemizedReceipt = CONST.DISABLED_MAX_EXPENSE_VALUE; + transaction.amount = -300; // $3.00 + const existingViolations: TransactionViolation[] = [{name: CONST.VIOLATIONS.ITEMIZED_RECEIPT_REQUIRED, type: CONST.VIOLATION_TYPES.VIOLATION, showInReview: true}]; + + // When the category is changed to "never require itemized receipt" + const result = ViolationsUtils.getViolationsOnyxData(transaction, existingViolations, policy, policyTags, policyCategories, false, false); + const violations = result.value as TransactionViolation[]; + + // Then the itemized violation should be removed and replaced with receiptRequired because the policy still requires receipts + const itemizedViolation = violations.find((v: TransactionViolation) => v.name === CONST.VIOLATIONS.ITEMIZED_RECEIPT_REQUIRED); + const receiptViolation = violations.find((v: TransactionViolation) => v.name === CONST.VIOLATIONS.RECEIPT_REQUIRED); + expect(itemizedViolation).toBeUndefined(); + expect(receiptViolation).toBeDefined(); + }); + + it('should update itemizedReceiptRequired violation data when threshold changes', () => { + // Given a transaction with an existing itemizedReceiptRequired violation that has stale threshold data + policy.maxExpenseAmountNoItemizedReceipt = 7500; // $75.00 + transaction.amount = -10000; // $100.00 + const existingViolations: TransactionViolation[] = [ + {name: CONST.VIOLATIONS.ITEMIZED_RECEIPT_REQUIRED, type: CONST.VIOLATION_TYPES.VIOLATION, showInReview: true, data: {formattedLimit: '$50.00'}}, + ]; + + // When violations are recalculated after the policy threshold changed + const result = ViolationsUtils.getViolationsOnyxData(transaction, existingViolations, policy, policyTags, policyCategories, false, false); + const violations = result.value as TransactionViolation[]; + + // Then the violation should have updated threshold data to reflect the current policy settings + const itemizedViolation = violations.find((v: TransactionViolation) => v.name === CONST.VIOLATIONS.ITEMIZED_RECEIPT_REQUIRED); + expect(itemizedViolation).toBeDefined(); + expect(itemizedViolation?.data?.formattedLimit).not.toBe('$50.00'); + }); + + it('should replace receiptRequired with itemizedReceiptRequired when category changes to always require itemized', () => { + // Given a transaction with a receiptRequired violation from the policy threshold + policy.maxExpenseAmountNoReceipt = 2500; // $25.00 + policyCategories.Food.maxAmountNoReceipt = undefined; + policyCategories.Food.maxAmountNoItemizedReceipt = 0; + transaction.amount = -5000; // $50.00 + const existingViolations: TransactionViolation[] = [{name: CONST.VIOLATIONS.RECEIPT_REQUIRED, type: CONST.VIOLATION_TYPES.VIOLATION, showInReview: true}]; + + // When the category is changed to "always require itemized receipts" + const result = ViolationsUtils.getViolationsOnyxData(transaction, existingViolations, policy, policyTags, policyCategories, false, false); + const violations = result.value as TransactionViolation[]; + + // Then itemized should supersede receipt because itemized is more restrictive + const receiptViolation = violations.find((v: TransactionViolation) => v.name === CONST.VIOLATIONS.RECEIPT_REQUIRED); + const itemizedViolation = violations.find((v: TransactionViolation) => v.name === CONST.VIOLATIONS.ITEMIZED_RECEIPT_REQUIRED); + expect(receiptViolation).toBeUndefined(); + expect(itemizedViolation).toBeDefined(); + }); + + it('should remove both violations when category is set to never for both receipt and itemized', () => { + // Given a transaction with an itemizedReceiptRequired violation from the policy + policy.maxExpenseAmountNoReceipt = 100; // $1.00 + policy.maxExpenseAmountNoItemizedReceipt = 100; // $1.00 + policyCategories.Food.maxAmountNoReceipt = CONST.DISABLED_MAX_EXPENSE_VALUE; + policyCategories.Food.maxAmountNoItemizedReceipt = CONST.DISABLED_MAX_EXPENSE_VALUE; + transaction.amount = -10000; // $100.00 + const existingViolations: TransactionViolation[] = [{name: CONST.VIOLATIONS.ITEMIZED_RECEIPT_REQUIRED, type: CONST.VIOLATION_TYPES.VIOLATION, showInReview: true}]; + + // When the category is set to "never" for both receipt types + const result = ViolationsUtils.getViolationsOnyxData(transaction, existingViolations, policy, policyTags, policyCategories, false, false); + const violations = result.value as TransactionViolation[]; + + // Then no receipt violations should exist because category overrides take precedence over policy settings + const itemizedViolation = violations.find((v: TransactionViolation) => v.name === CONST.VIOLATIONS.ITEMIZED_RECEIPT_REQUIRED); + const receiptViolation = violations.find((v: TransactionViolation) => v.name === CONST.VIOLATIONS.RECEIPT_REQUIRED); + expect(itemizedViolation).toBeUndefined(); + expect(receiptViolation).toBeUndefined(); + }); }); describe('policyRequiresCategories', () => { diff --git a/tests/unit/hooks/useFilterSelectedTransactionsTest.ts b/tests/unit/hooks/useFilterSelectedTransactionsTest.ts new file mode 100644 index 000000000000..f14cc79ca28a --- /dev/null +++ b/tests/unit/hooks/useFilterSelectedTransactionsTest.ts @@ -0,0 +1,160 @@ +import {renderHook} from '@testing-library/react-native'; +import useFilterSelectedTransactions from '@hooks/useFilterSelectedTransactions'; +import type {Transaction} from '@src/types/onyx'; +import createRandomTransaction from '../../utils/collections/transaction'; + +// Mock variables that can be modified in tests +let mockSelectedTransactionIDs: string[] = []; +const mockSetSelectedTransactions = jest.fn(); + +jest.mock('@components/Search/SearchContext', () => ({ + useSearchContext: () => ({ + selectedTransactionIDs: mockSelectedTransactionIDs, + setSelectedTransactions: mockSetSelectedTransactions, + clearSelectedTransactions: jest.fn(), + selectedTransactions: {}, + currentSearchHash: 12345, + }), +})); + +describe('useFilterSelectedTransactions', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockSelectedTransactionIDs = []; + }); + + it('should not call setSelectedTransactions when no transactions are selected', () => { + const transactions = [createRandomTransaction(1), createRandomTransaction(2)]; + const trans0 = transactions.at(0); + const trans1 = transactions.at(1); + if (trans0) { + trans0.transactionID = 'trans1'; + } + if (trans1) { + trans1.transactionID = 'trans2'; + } + + mockSelectedTransactionIDs = []; + + renderHook(() => useFilterSelectedTransactions(transactions)); + + expect(mockSetSelectedTransactions).not.toHaveBeenCalled(); + }); + + it('should not call setSelectedTransactions when all selected transactions exist in the list', () => { + const transactions = [createRandomTransaction(1), createRandomTransaction(2)]; + const trans0 = transactions.at(0); + const trans1 = transactions.at(1); + if (trans0) { + trans0.transactionID = 'trans1'; + } + if (trans1) { + trans1.transactionID = 'trans2'; + } + + mockSelectedTransactionIDs = ['trans1', 'trans2']; + + renderHook(() => useFilterSelectedTransactions(transactions)); + + expect(mockSetSelectedTransactions).not.toHaveBeenCalled(); + }); + + it('should filter out selected transactions that no longer exist in the transactions list', () => { + const transactions = [createRandomTransaction(1)]; + const trans0 = transactions.at(0); + if (trans0) { + trans0.transactionID = 'trans1'; + } + + // trans2 and trans3 are selected but don't exist in transactions + mockSelectedTransactionIDs = ['trans1', 'trans2', 'trans3']; + + renderHook(() => useFilterSelectedTransactions(transactions)); + + expect(mockSetSelectedTransactions).toHaveBeenCalledWith(['trans1']); + }); + + it('should clear all selections when all selected transactions are removed', () => { + const transactions = [createRandomTransaction(1)]; + const trans0 = transactions.at(0); + if (trans0) { + trans0.transactionID = 'trans1'; + } + + // All selected transactions don't exist in the current list + mockSelectedTransactionIDs = ['trans2', 'trans3']; + + renderHook(() => useFilterSelectedTransactions(transactions)); + + expect(mockSetSelectedTransactions).toHaveBeenCalledWith([]); + }); + + it('should update filtered transactions when transactions list changes', () => { + // Initial state: 3 transactions, 3 selected + const initialTransactions = [createRandomTransaction(1), createRandomTransaction(2), createRandomTransaction(3)]; + const initTrans0 = initialTransactions.at(0); + const initTrans1 = initialTransactions.at(1); + const initTrans2 = initialTransactions.at(2); + if (initTrans0) { + initTrans0.transactionID = 'trans1'; + } + if (initTrans1) { + initTrans1.transactionID = 'trans2'; + } + if (initTrans2) { + initTrans2.transactionID = 'trans3'; + } + + mockSelectedTransactionIDs = ['trans1', 'trans2', 'trans3']; + + const {rerender} = renderHook(({transactions}) => useFilterSelectedTransactions(transactions), { + initialProps: {transactions: initialTransactions}, + }); + + // No filtering needed initially + expect(mockSetSelectedTransactions).not.toHaveBeenCalled(); + + // Now remove trans2 from the list (simulating deletion) + const updatedTransactions = [initialTransactions.at(0), initialTransactions.at(2)].filter((t): t is Transaction => !!t); + + rerender({transactions: updatedTransactions}); + + // Should filter out trans2 from selection + expect(mockSetSelectedTransactions).toHaveBeenCalledWith(['trans1', 'trans3']); + }); + + it('should handle empty transactions list', () => { + const transactions: Transaction[] = []; + + mockSelectedTransactionIDs = ['trans1', 'trans2']; + + renderHook(() => useFilterSelectedTransactions(transactions)); + + // All selections should be cleared since no transactions exist + expect(mockSetSelectedTransactions).toHaveBeenCalledWith([]); + }); + + it('should preserve order of selected transactions after filtering', () => { + const transactions = [createRandomTransaction(1), createRandomTransaction(2), createRandomTransaction(3)]; + const trans0 = transactions.at(0); + const trans1 = transactions.at(1); + const trans2 = transactions.at(2); + if (trans0) { + trans0.transactionID = 'trans1'; + } + if (trans1) { + trans1.transactionID = 'trans2'; + } + if (trans2) { + trans2.transactionID = 'trans3'; + } + + // Selected in specific order, with some non-existent IDs interspersed + mockSelectedTransactionIDs = ['trans3', 'nonexistent1', 'trans1', 'nonexistent2', 'trans2']; + + renderHook(() => useFilterSelectedTransactions(transactions)); + + // Should maintain the original order of valid selections + expect(mockSetSelectedTransactions).toHaveBeenCalledWith(['trans3', 'trans1', 'trans2']); + }); +}); diff --git a/tests/unit/useSearchHighlightAndScrollTest.ts b/tests/unit/useSearchHighlightAndScrollTest.ts index 76e3a3b27f4f..2c0a6a4e9cb4 100644 --- a/tests/unit/useSearchHighlightAndScrollTest.ts +++ b/tests/unit/useSearchHighlightAndScrollTest.ts @@ -1,9 +1,11 @@ /* eslint-disable @typescript-eslint/naming-convention */ import {renderHook} from '@testing-library/react-native'; +import Onyx from 'react-native-onyx'; import useSearchHighlightAndScroll from '@hooks/useSearchHighlightAndScroll'; import type {UseSearchHighlightAndScroll} from '@hooks/useSearchHighlightAndScroll'; import {search} from '@libs/actions/Search'; import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; jest.mock('@libs/actions/Search'); jest.mock('@react-navigation/native', () => ({ @@ -257,6 +259,82 @@ describe('useSearchHighlightAndScroll', () => { expect(result.current.newSearchResultKeys?.size).toBe(2); }); + it('should return new search result keys for manually highlighted expenses', async () => { + const spyOnMergeTransactionIdsHighlightOnSearchRoute = jest + .spyOn(require('@libs/actions/Transaction'), 'mergeTransactionIdsHighlightOnSearchRoute') + .mockImplementationOnce(jest.fn()); + // We need to mock requestAnimationFrame to mimic long Onyx merge overhead + jest.spyOn(global, 'requestAnimationFrame').mockImplementation((callback: FrameRequestCallback) => { + callback(performance.now()); + return 0; + }); + + await Onyx.merge(ONYXKEYS.TRANSACTION_IDS_HIGHLIGHT_ON_SEARCH_ROUTE, {[baseProps.queryJSON.type]: {'3': true}}); + + const {rerender, result} = renderHook((props: UseSearchHighlightAndScroll) => useSearchHighlightAndScroll(props), { + initialProps: baseProps, + }); + const updatedProps1 = { + ...baseProps, + searchResults: { + ...baseProps.searchResults, + data: { + transactions_1: { + transactionID: '1', + }, + transactions_2: { + transactionID: '2', + }, + }, + }, + transactions: { + '1': {transactionID: '1'}, + '2': {transactionID: '2'}, + '3': {transactionID: '3'}, + }, + previousTransactions: { + '1': {transactionID: '1'}, + }, + } as unknown as UseSearchHighlightAndScroll; + + // When there is no data yet, even if the transactionID has been added to manual highlight transactionIDs, + // it still will not be included in newSearchResultKeys. + rerender(updatedProps1); + expect(result.current.newSearchResultKeys?.size).toBe(2); + expect([...(result.current.newSearchResultKeys ?? new Set())]).not.toContain('transactions_3'); + + // When the data contains the highlight transactionID, it will be highlighted. + const updatedProps2 = { + ...updatedProps1, + searchResults: { + ...updatedProps1.searchResults, + data: { + transactions_1: { + transactionID: '1', + }, + transactions_2: { + transactionID: '2', + }, + transactions_3: { + transactionID: '3', + }, + }, + }, + } as unknown as UseSearchHighlightAndScroll; + + rerender(updatedProps2); + expect(result.current.newSearchResultKeys?.size).toBe(1); + expect([...(result.current.newSearchResultKeys ?? new Set())]).toContain('transactions_3'); + + // Wait 1s for the timer in useSearchHighlightAndScroll to complete. + await new Promise((resolve) => { + setTimeout(resolve, 1000); + }); + + expect(spyOnMergeTransactionIdsHighlightOnSearchRoute).toHaveBeenCalledTimes(1); + expect(spyOnMergeTransactionIdsHighlightOnSearchRoute).toHaveBeenCalledWith(baseProps.queryJSON.type, {'3': false}); + }); + it('should return multiple new search result keys when there are multiple new chats', () => { const chatProps = { ...baseProps, diff --git a/tests/unit/useSubPageTest.tsx b/tests/unit/useSubPageTest.tsx index f441f5e1a0f3..79f7d2e180ac 100644 --- a/tests/unit/useSubPageTest.tsx +++ b/tests/unit/useSubPageTest.tsx @@ -12,6 +12,11 @@ jest.mock('@libs/Navigation/Navigation', () => ({ goBack: jest.fn(), })); +jest.spyOn(global, 'requestAnimationFrame').mockImplementation((callback) => { + callback(0); + return 0; +}); + type AnyRoute = RouteProp | undefined>, string>; const mockUseRoute = jest.fn(); jest.mock('@react-navigation/native', () => { @@ -64,9 +69,14 @@ describe('useSubPage hook', () => { }); describe('initialization', () => { - it('returns CurrentPage, isEditing, currentPageName, pageIndex, prevPage, nextPage, lastPageIndex, moveTo, resetToPage', () => { + it('returns CurrentPage, isEditing, currentPageName, pageIndex, prevPage, nextPage, lastPageIndex, moveTo, resetToPage, isRedirecting', () => { const pages = createMockPages(); const buildRoute = createBuildRoute(); + mockUseRoute.mockReturnValue({ + name: 'TestRoute', + key: 'test-key', + params: {subPage: 'page1'}, + } as AnyRoute); const {result} = renderHook(() => useSubPage({ @@ -76,13 +86,14 @@ describe('useSubPage hook', () => { }), ); - const {CurrentPage, isEditing, currentPageName, pageIndex, prevPage, nextPage, lastPageIndex, moveTo, resetToPage} = result.current; + const {CurrentPage, isEditing, currentPageName, pageIndex, prevPage, nextPage, lastPageIndex, moveTo, resetToPage, isRedirecting} = result.current; expect(CurrentPage).toBe(mockSubPageOne); expect(isEditing).toBe(false); expect(currentPageName).toBe('page1'); expect(pageIndex).toBe(0); expect(lastPageIndex).toBe(3); + expect(isRedirecting).toBe(false); expect(typeof prevPage).toBe('function'); expect(typeof nextPage).toBe('function'); expect(typeof moveTo).toBe('function'); @@ -140,6 +151,22 @@ describe('useSubPage hook', () => { expect(Navigation.navigate).toHaveBeenCalledWith(buildRoute('page3'), {forceReplace: true}); }); + it('returns isRedirecting true when no subPage param is present in URL', () => { + const pages = createMockPages(); + const buildRoute = createBuildRoute(); + + const {result} = renderHook(() => + useSubPage({ + pages, + onFinished: mockOnFinished, + startFrom: 1, + buildRoute, + }), + ); + + expect(result.current.isRedirecting).toBe(true); + }); + it('does not navigate on mount when subPage param is present in URL', () => { const pages = createMockPages(); const buildRoute = createBuildRoute();